1. Multilayer abstraction
When you’re just starting to program, everything seems simple: you write a class, call a method, and get a result. But in real projects things get complicated: dozens of classes, hundreds of methods, thousands of lines of code... And if a team is working on the project, it gets even harder! How do you avoid drowning in this chaos?
The answer is to divide the complex into the simple, and even better — into levels of abstraction.
What are levels of abstraction?
Imagine them as floors in a large building: each floor has its own life, but all floors are connected. In programming, it’s common to distinguish the following layers (levels):
- User interface (UI) — what the user sees.
- Business logic — the rules and processes that implement the essence of the application.
- Data access (DAO, Repository) — working with a database or files.
Each layer works with abstractions without knowing the details of other layers. For example, business logic doesn’t care how exactly the user interface is implemented or how data is stored — it cares that there are methods like saveOrder() or findUserById().
An everyday analogy
Imagine a restaurant. Guests (UI) place an order through a waiter (interface abstraction), the chef (business logic) cooks the dish, and the storekeeper (data access) monitors inventory in the storeroom. Guests don’t know how exactly the chef prepares the dish, and the chef doesn’t care where the potatoes are — as long as they’re at hand.
2. Example: multilayer architecture in practice
Let’s evolve our training project — for example, a task tracker (task manager). We already know how to create classes for tasks; now we’ll increase the complexity and split the application into layers.
Define abstractions
- Task — an abstract description of a task: a task has a title, a status, and methods to execute it.
- TaskRepository — an abstraction for storing tasks (it doesn’t matter where — in memory, a file, a database).
- TaskService — business logic: adding tasks, searching, execution.
Abstract classes and interfaces
// Business logic layer
public abstract class Task {
private String title;
private boolean completed;
public Task(String title) {
this.title = title;
this.completed = false;
}
public abstract void complete();
public String getTitle() { return title; }
public boolean isCompleted() { return completed; }
protected void setCompleted(boolean completed) { this.completed = completed; }
}
// Data storage layer (abstraction)
public interface TaskRepository {
void save(Task task);
Task findByTitle(String title);
List<Task> findAll();
}
Layer implementations
Implementation of Task
public class WorkTask extends Task {
private String deadline;
public WorkTask(String title, String deadline) {
super(title);
this.deadline = deadline;
}
@Override
public void complete() {
setCompleted(true);
System.out.println("Work task '" + getTitle() + "' completed by the deadline " + deadline);
}
}
Implementation of TaskRepository
public class InMemoryTaskRepository implements TaskRepository {
private List<Task> tasks = new ArrayList<>();
@Override
public void save(Task task) {
tasks.add(task);
}
@Override
public Task findByTitle(String title) {
for (Task task : tasks) {
if (task.getTitle().equals(title)) {
return task;
}
}
return null;
}
@Override
public List<Task> findAll() {
return new ArrayList<>(tasks);
}
}
Implementation of TaskService
public class TaskService {
private TaskRepository repository;
public TaskService(TaskRepository repository) {
this.repository = repository;
}
public void addTask(Task task) {
repository.save(task);
}
public void completeTask(String title) {
Task task = repository.findByTitle(title);
if (task != null) {
task.complete();
} else {
System.out.println("Task not found: " + title);
}
}
public void showAllTasks() {
for (Task task : repository.findAll()) {
System.out.println(task.getTitle() + " — " + (task.isCompleted() ? "completed" : "not completed"));
}
}
}
Usage in the main class
public class Main {
public static void main(String[] args) {
TaskRepository repo = new InMemoryTaskRepository();
TaskService service = new TaskService(repo);
service.addTask(new WorkTask("Write report", "2025-07-15"));
service.addTask(new WorkTask("Prepare presentation", "2025-07-16"));
service.showAllTasks();
service.completeTask("Write report");
service.showAllTasks();
}
}
What did we get?
- The main class (Main) doesn’t know how the task store is implemented — it works with the TaskRepository abstraction.
- TaskService doesn’t know what task types exist — it works with the abstract class Task.
- If tomorrow we want to store tasks in a database instead of memory, we just implement a new class DatabaseTaskRepository without rewriting business logic and UI.
- If a new task type appears, for example, HomeTask, we just add a new subclass.
3. Benefits for teamwork
In large projects it’s rare for one person to write everything. Usually the team is split into “front-end developers,” “back-end developers,” “data-store developers,” etc. How do abstractions help them avoid stepping on each other’s toes?
Separation of responsibilities
Everyone works at their own level of abstraction.
- One developer writes a TaskRepository implementation to work with the database.
- Another handles the business logic (TaskService).
- A third builds the user interface.
The contract between layers is defined by abstractions.
As long as everyone agrees that TaskRepository has methods like save, findByTitle, findAll, the implementation details don’t matter.
Ease of testing and swapping components
- You can easily swap one implementation for another (for example, use InMemoryTaskRepository for tests and a database implementation in production).
- A tester can replace the data layer with a stub (mock) to test business logic in isolation.
Independent evolution
- If someone decides to add a new task type, they don’t break existing code — they just implement a new subclass of Task.
- If a new way to store data appears, only the interface implementation changes, and the rest of the code remains untouched.
4. Best practices: how not to overdo abstractions
Abstraction is like salt in a dish: without it, it’s bland; if you overdo it, you spoil everything. Here are a few tips:
Use abstractions where they truly simplify the system.
Don’t create an abstract class just for the sake of it. If you have only one task type, the abstraction might be unnecessary.
Document abstract classes and methods.
Good documentation helps clarify exactly what a subclass must implement and why.
Strive for meaningful abstractions.
An abstract class should represent genuinely common behavior and/or state.
Don’t mix responsibilities.
Don’t add methods to an abstract class that only one descendant needs.
5. Abstraction in large systems: a real-world example
Let’s see how abstraction works in a truly large project — for example, an online store.
System layers
- Controllers (UI): accept user requests (for example, “place an order”).
- Services (business logic): check product availability, calculate discounts, place the order.
- Repositories (data access): save orders, products, users to the database.
Example abstractions
// Abstraction for the order processing service
public interface OrderService {
void createOrder(Order order);
Order findOrderById(String id);
}
// Abstraction for the order repository
public interface OrderRepository {
void save(Order order);
Order findById(String id);
}
Each layer knows only about its own abstraction. If tomorrow the team decides to store orders in the cloud, only the OrderRepository implementation changes.
Interaction between layers — diagram
[UI/Controller] <--> [OrderService (abstraction)] <--> [OrderRepository (abstraction)] <--> [Database]
- Each layer works with an abstraction without knowing the details of the lower layer.
- This allows each layer to be developed, tested, and improved independently.
Abstraction and code maintenance
- Easy to add new capabilities (new types of tasks, payments, transportation).
- Easy to fix bugs (fix a bug in one place — all descendants get the update).
- Easy to test (you can replace layers with “mocks” for unit tests).
6. Common mistakes when designing abstractions
Mistake No. 1: Excessive abstraction. Sometimes you want to create an abstract class for every little thing. But if you have only one kind of entity, don’t create an abstraction just for fashion — it will only complicate the code.
Mistake No. 2: Too blurry an abstraction. If your abstract class describes too much and lacks a clear responsibility, its subclasses will be forced to implement unnecessary methods or keep “dead” fields.
Mistake No. 3: Violating the single-responsibility principle. An abstract class should be responsible for only one area of behavior. Don’t mix, for example, data-storage methods and business-logic methods in the same abstract class.
Mistake No. 4: Tight coupling between layers. If the business-logic layer directly depends on a specific storage implementation (for example, uses new InMemoryTaskRepository() inside itself), then replacing the storage will require rewriting all the code. Use abstractions (interfaces, abstract classes) to loosen coupling.
Mistake No. 5: Insufficient documentation. An abstraction is a contract, and it must be clearly described. If you don’t specify what a subclass should do, it’s easy to get unexpected errors or “creative” interpretations from teammates.
GO TO FULL VERSION