Skip to content
beginner Phase 4 · Layered, Hexagonal, and Modular Monolith Architectures

Layered Architecture

Organize presentation, application, domain, and infrastructure layers in order.

50m
0 problems
Topic Progress 0%

The Controller That Executes SQL: What Horizontal Layers Actually Promise

A pull request on an e-commerce codebase adds a discount rule. The code lands in CheckoutController: three nested if statements over the session cart, a raw UPDATE orders SET total_cents = ..., and a redirect with a flash message. It ships and it works. Six months later the same rule must also run from an admin bulk tool and a nightly email job, and the team copies it twice — because the rule lives in the controller where nothing else can reach it, and the SQL sits beside it where no shared transaction boundary exists.

Layering exists to make that outcome structurally hard rather than merely discouraged. Layered architecture stacks presentation, business, and data concerns into horizontal tiers, and the classic four-tier version looks like this:

Layer Responsibility May depend on
Presentation Routes, templates, request/response DTOs, input validation Application services
Application Use-case orchestration, transaction boundaries, authorization Domain + repository interfaces
Domain Entities, invariants, business rules, domain exceptions Nothing outside itself
Infrastructure Postgres access, message brokers, SMTP, third-party APIs Domain interfaces it implements

Two rules make the diagram load-bearing instead of decorative. Calls flow downward — presentation invokes application, application invokes domain, and nobody calls back up. Dependencies point the same way — source-level imports follow the call direction, so the domain layer compiles without the web framework or the JDBC driver on its classpath. The second rule is the one teams forget, and it is the one that pays: if domain.Order imports javax.servlet.Httpsession, you have a layered diagram but a spaghetti codebase.

Here is the discount rule in its proper home:

// domain layer — no Spring, no JDBC, no HTTP imports
public class Order {
    public Money totalWith(Customer customer, Coupon coupon) {
        Money base = this.subtotal();
        if (customer.isVip() && coupon.appliesTo(this.placedAt())) {
            return base.minus(base.percentage(coupon.percent()));
        }
        return base;
    }
}

// application layer — orchestrates, never decides
public class ApplyCoupon {
    public OrderView execute(ApployCouponCmd cmd) {
        return transactions.required(() -> {
            Order order = orders.find(cmd.orderId());   // repo interface
            Order updated = order.totalWith(customers.get(cmd.customerId()), coupons.get(cmd.code()));
            orders.save(updated);
            return viewOf(updated);
        });
    }
}

Notice what the application layer does: it finds things, opens a transaction, calls the domain, saves, and maps to a view. It orchestrates; it never decides. Every branch that expresses a business rule belongs one layer down, where the bulk tool and the nightly job can call it without going through HTTP.

Enforcement cannot be social. Layering decays under deadline pressure one convenience import at a time, so mature codebases encode the dependency rule in a machine check — ArchUnit in Java (noClasses().that().resideInAPackage("..domain..").should().dependOnClassesThat().resideInAnyPackage("..web..", "..infra..")), import-linter in Python, ESLint boundaries plugins for TypeScript. The check fails CI on the first violation, which converts "please don't do that" into a red build. That single mechanism is the difference between a layered architecture and layered-and-hollow.

Interviewers ask layered architecture as a warm-up: expect "what can call what?" and "where does a business rule live?" The answers above — downward only, in the domain — are the whole game. When you have seen a codebase where the checks exist, say so; naming the enforcement tool separates people who drew layers from people who kept them.

The Anemic Domain Model: When Services Quietly Eat the Domain Layer

Look at this entity from a real Spring codebase:

public class Invoice {
    private Long id;
    private BigDecimal amount;
    private String status;          // string, not enum
    private Date dueDate;
    // getters and setters only
}

public class InvoiceService {
    public void markOverdue(Long id) {
        Invoice inv = repo.findById(id).orElseThrow();
        if (inv.getDueDate().before(new Date()) && !"PAID".equals(inv.getStatus())) {
            inv.setStatus("OVERDUE");
            repo.save(inv);
            mailer.send(inv.getCustomerEmail(), "Invoice overdue");
        }
    }
}

The domain layer exists, the service layer exists, and yet every rule lives in the service. This is the anemic domain model — objects that are bags of getters and setters with all behavior hoisted into procedural service classes. It compiles, it tests (slowly), and it fails the only question that matters: where does a business rule live? Here the answer is "in whatever service someone remembered to call," which means a second entry point can (and will) mutate status to OVERDUE without checking the due date.

The symptoms are consistent enough to pattern-match. Service classes grow past 500 lines while entities stay under 100. Validation logic appears twice: once in the controller for the HTTP response, once in the service for the rule — and the two disagree. setStatus("OVERDUE") is callable from anywhere because a setter cannot say no. Refactoring is search-and-replace across services rather than moving one method.

The cure is moving the decision behind the entity's invariant:

public class Invoice {
    private InvoiceStatus status;      // enum: SENT, PAID, OVERDUE
    private LocalDate dueDate;

    public void markOverdue(Clock clock) {
        if (status == InvoiceStatus.PAID) {
            throw new IllegalStateException("paid invoices never go overdue");
        }
        if (!dueDate.isBefore(clock.today())) {
            throw new IllegalStateException("not past due yet");
        }
        this.status = InvoiceStatus.OVERDUE;
        this.registerEvent(new InvoiceOverdue(id));   // side effects as events
    }
}

Now the impossible states are unrepresentable: there is no setter, PAID and OVERDUE cannot both be true, and the caller cannot skip the due-date check because the check is the only way in. The service shrinks to orchestration — load, call, save, publish events — which is exactly the division of labor the layered diagram promised. The anemic model is what happens when the diagram is honored structurally but violated semantically: four folders exist, and the domain folder holds DTOs.

Mail sending deserves a note. Pushing mailer.send out of the entity is not a concession to purity; entities raise domain events (InvoiceOverdue), and the application layer decides whether that means an email now, an outbox row, or a message on a broker. The domain stays synchronous and testable; delivery becomes somebody else's retry problem.

The testing consequence seals the argument. Against the rich entity, the overdue test constructs an Invoice, a fixed Clock, and asserts one exception or one status transition — microseconds, no mocks. Against the anemic version, the same test must mock the repository, the loader, and the mailer before the if statement even runs, because the rule cannot execute without its procedural shell. When a business rule needs infrastructure to be tested at all, the layer boundary has already failed.

In interviews, "what is an anemic domain model and why is it a smell?" is a filter question. A strong answer names the failure — behavior scattered across services, invariants enforced by convention, duplicated validation — shows the refactored version above, and concedes the trade-off: a rich domain pays off when rules are non-trivial, and a CRUD app with three forms gains little from ceremony it does not need.

Four Layers, Two Layers, or None: Deciding What Your App Earns

A team of three ships an internal admin tool. Their first commit creates web/, services/, domain/, infrastructure/, and config/ packages — five folders, twelve files, one endpoint. Three weeks later half the "domain" classes are empty pass-throughs and the developer who wrote the CRUD endpoint had to jump through four files to change a column name. Layering has a cost curve, and paying it before the complexity arrives is waste.

Layers pay when they absorb variation you actually have. The mechanical test: list the things that change independently in your system. A customer-facing web app where the UI, the rules, and the storage genuinely evolve on different cadences earns four layers — designers touch presentation, domain experts touch rules, DBAs touch schema, and the layers let those changes land without merge conflicts. An event consumer with one message type and one table has nothing to separate; two files (handler + storage) are the honest structure, and splitting them into tiers just distributes one idea across four hops.

The costs are concrete:

Cost What it looks like on day 100
Navigation A one-line rule change touches controller, service, and entity files
Mapping DTO to entity to row conversions duplicated at every boundary
Latency of understanding A new hire needs the layer conventions before their first PR
Pressure Under deadline, people "just this once" call the repository from the controller — violations compound

None of those are fatal, but they are the bill. The pragmatic middle is the two-layer app: a presentation layer and everything else, with a domain package inside "everything else" that you commit to keeping framework-free. You can split later along the seams you have proven you need — and the split is mechanical when the dependency rule was honored from the start.

The failure mode on the other side deserves equal suspicion: the tiered call chain, where a request walks controller → facade → service → manager → DAO, each layer forwarding with the same five parameters and adding one log line. Every hop is locally reasonable and the result is a change to one field requiring edits in five files. The fix is deleting a layer whenever two adjacent layers have identical method signatures — forwarding is not a responsibility. Interviewers who have worked in enterprise shops light up at this; volunteering "layers are not free, and an unnecessary one is worse than none" signals that you have run a codebase, not just drawn one.

Practical decision rule to state in a design review: start with the minimum structure that keeps business rules out of framework code — domain package plus orchestration — and promote to strict four-layer enforcement when a second delivery mechanism or a second data store appears. A bulk import tool or a message consumer forces the question, and by then the rules already live in the domain because the anemic-model trap was avoided. That narrative — cost-aware, evidence-triggered — is exactly what separates an architecture answer from a template answer, and it flows directly into Hexagonal Architecture with Ports and Adapters, which takes the same dependency rule and pushes it further.

Quiz

1. In a correctly layered application, which dependency is legal?

Question 1 options

2. Which observation is the strongest signal of an anemic domain model?

Question 2 options

3. A team wants to prevent domain classes from importing web framework types. What is the reliable enforcement?

Question 3 options

4. A three-person team builds an internal tool with one endpoint and one table. How many layers does the cost curve justify?

Question 4 options

Flashcards

Question

What are the two rules that define a layered architecture?

Answer

Calls flow downward (presentation → application → domain → infrastructure, never back up), and source-level dependencies point the same way — so the domain layer compiles with no web framework or database driver on its classpath. A diagram without the second rule is decorative.

Question

What is an anemic domain model, and what is its primary risk?

Answer

An entity that is only getters and setters while all business rules live in service classes. Risk: invariants are enforced by convention, so any caller can put an object into an illegal state (e.g., setStatus("OVERDUE") on a paid invoice), and rules are duplicated or skipped when a second entry point appears.

Question

What should a service layer do after the domain model is made rich?

Answer

Orchestrate only: load aggregates through repository interfaces, call one domain method, save, and publish domain events. It owns transaction boundaries, authorization, and mapping to views — it never contains branch logic that expresses a business rule.

Question

Name two concrete costs of layering and one trigger that makes those costs worth paying.

Answer

Costs: navigation and mapping overhead (a one-line rule change touches controller/service/entity files; DTO↔entity↔row conversion is duplicated at each boundary), plus onboarding time. Trigger: a second delivery mechanism or data store (bulk import, message consumer) — layers then absorb the variation instead of duplicating rules.

Question

What is the 'tiered call chain' anti-pattern and its fix?

Answer

Controller → facade → service → manager → DAO where each layer merely forwards the same parameters and adds a log line, so one field change edits five files. Fix: delete a layer whenever two adjacent layers have identical signatures — forwarding is not a responsibility.

Revision Notes

Key Takeaways

  • 1. Layering is two rules — downward calls and downward dependencies — and only the second one survives contact with deadlines
  • 2. The domain layer must compile without web or storage on its classpath; that single property is what lets rules be reused by controllers, jobs, and consumers
  • 3. An anemic domain model honors the folders but violates the intent: rules scattered in services mean invariants depend on callers remembering the checks
  • 4. Entities should make illegal states unrepresentable — enum statuses, no public setters, methods that throw — and raise events instead of sending mail
  • 5. Layers have a cost curve; pay it when a second delivery mechanism or store appears, and delete forwarding layers with identical signatures

Interview Tips

  • • 'Where does a business rule live?' — in the domain layer, behind an entity method that enforces its own preconditions; the service only orchestrates — then mention the bulk-tool scenario as the reuse proof
  • • 'What is an anemic domain model?' — data bags plus procedural services; name the symptoms (fat services, duplicated validation, callable setters) and show the refactored markOverdue(Clock) version
  • • 'How do you keep layers from eroding?' — automated dependency rules in CI (ArchUnit/import-linter), because reviews catch only what reviewers happen to notice
  • • 'Isn't layering overkill?' — say yes for a one-endpoint tool (two layers suffices), and name the trigger to promote: a second entry point or data store forces rules out of framework code

Cheat Sheet

Layered Architecture Cheat Sheet

The four tiers and what each may import:

Layer Owns May depend on
Presentation routes, DTOs, input validation application
Application use-case orchestration, transactions, authz domain + repo interfaces
Domain entities, invariants, rules nothing outside itself
Infrastructure Postgres, brokers, SMTP, APIs domain interfaces it implements

Two rules: calls flow downward; dependencies point downward. Enforce with ArchUnit / import-linter / ESLint boundaries — CI, not code review.

Where code lives:

// rule in the domain — callable by web, bulk tool, and cron alike
customer.applyCoupon(coupon, clock.now());

// service: load → call → save → publish events (orchestrate, never decide)

Anemic model red flags: services >500 lines, entities <100, validation duplicated in controller and service, public setters that can reach illegal states. Cure: enum statuses, private fields, markOverdue(Clock) that throws instead of a setter; side effects leave as domain events.

When to layer: second delivery mechanism or second data store appears. Minimum viable structure = presentation + the rest with a framework-free domain package.

Delete a layer when two adjacent layers have identical signatures — forwarding is not a responsibility.

Continue Learning