Be Software Architecture Wise
Be Wise Apps

Leer en español · Lire en français · Auf Deutsch lesen

Section 5.2 of 5.8 · Intermediate · 24 min read

Layered architecture

Video lesson

5 min · narrated, with captions
Listen only (audio)
Read the transcript

Layered architecture is the most widely used style for business applications, often chosen by default rather than by decision. In this lesson you'll learn how it works, what it costs, and how to stop its rules eroding.

Think of a restaurant. Customers talk only to waiters. Waiters pass orders to the kitchen and never go into the storeroom themselves. Cooks fetch ingredients from the storeroom staff, and only the storeroom staff deal with suppliers. Each group has one job and talks only to the group next to it, so you can retrain the waiters without touching the kitchen. A layered application works the same way.

Here's a layered food-ordering application. A request from the browser or app arrives at the presentation layer, which handles screens, HTTP and JSON. The business layer holds the rules and use cases: how an order is priced, and when it may be cancelled. The persistence layer turns business objects into stored data and back. Only it talks to the database. These layers are closed: a request must pass through them. Presentation may not reach past the business layer into persistence, even when that would be quicker to write. And no layer may ever call upwards. The services layer, for emails, PDF receipts and audit records, is open. Only some operations need it, so the business layer may call it, or go straight past it to persistence.

Closed layers give you layers of isolation. Because presentation knows only the business layer's interface, the team can restructure the tables, or even change database product, without touching a single screen. Every exception that lets one layer skip another weakens this. If controllers query the database directly, a schema change now ripples into the presentation layer too.

Let's follow one request. A customer presses Place order. The presentation layer parses the request, checks that the user is signed in, and calls the order service. It knows nothing about tables or prices. The business layer checks the restaurant is open, prices the basket, and asks the repository to save the order. It also uses the open services layer to email a receipt. The persistence layer maps the order to rows and writes them in one database transaction. PostgreSQL commits, and the result flows back up. The customer sees their order number. Now the product team asks for a courier tip. That needs a request field, a maximum-tip rule, a new column and a migration. Four layers for one small feature: the cost of technical partitioning.

Layering has a classic anti-pattern, the architecture sinkhole: requests that pass through layers which only forward them. Viewing a menu is typical. The controller passes, the service passes, and only the repository does any work. Compare placing an order, where every layer adds behaviour: validating, pricing and checking rules, then saving in a transaction. Some pass-through is the price of isolation. As a rule of thumb, about a fifth of requests is normal. If most requests are pass-through, layering is probably the wrong style.

The style rates high on simplicity and low cost: familiar to almost every developer, with one code base, one deployment and one database. It rates low on deployability, scalability and fault tolerance. Any change redeploys everything, the whole application scales as one, and one memory leak can take it all down. It's a good fit for small and medium applications built by one team, and for applications that are mostly forms over data. It's a poor fit as the top level of a large system with several teams, because every team works in every layer. The usual next step is a modular monolith, with layers inside each module.

Layer rules decay quickly. Someone imports the repository straight into a controller to save twenty minutes, nobody notices, and the next developer copies it. The defence is an architecture test: an automated check of dependencies that runs in the build, and fails it when a rule is broken. In Python, import-linter reads the rules from a configuration file. In Java, ArchUnit expresses them as a unit test. Start by recording existing violations as known exceptions, so only new ones fail the build. Then reduce the exceptions over time.

The full section has the characteristics table, more anti-patterns and a working import-linter configuration. Next, in Section five point three, we keep one deployment but divide it by business domain: the modular monolith. See you there.

What you need to know

Definition and origin

A layered architecture organises an application into horizontal layers, each of which has one technical responsibility, offers services to the layer above and uses the services of the layer below. It is the most widely used style for business applications, often by default rather than by decision, because most web frameworks generate a project with folders for views, business logic and models.

The idea is old and comes from systems software. Edsger Dijkstra described the THE multiprogramming operating system (Communications of the ACM, 1968) as a hierarchy of levels, each built only on the ones beneath it, so that each level could be reasoned about and tested on its own. Network protocol stacks such as the OSI reference model use the same principle. In business software, layering arrived with client-server and three-tier systems in the 1990s, and POSA (1996) catalogued it as the Layers pattern. Garlan and Shaw listed layered systems among their basic styles, with the constraint that interactions are limited to adjacent layers.

The typical business application has four layers. The presentation layer handles the user interface and the protocol: HTTP requests, screens, JSON. The business layer holds the rules and use cases: how an order is priced, when it may be cancelled. The persistence layer turns business objects into stored data and back, usually through repositories and an object-relational mapper. The database layer is the database itself. Small applications sometimes merge business and persistence; larger ones add layers, such as an application layer that coordinates use cases above a domain layer that holds the core model. The style does not fix the number; it fixes the direction of dependency.

A layered food-ordering application

A layered food-ordering web application: presentation, business, an open services layer and persistence inside one deployment unit, with the database below; calls go downwards only, and the business layer may skip the open services layerFood-ordering web application (one deployment unit)Presentation layercontrollers, templates, JSON viewsBusiness layerorder service, pricing rulesServices layer (open)email, PDF receipts, auditPersistence layerrepositories, ORM mappingsDatabase layerPostgreSQLSQLmay skip theopen layerBrowser or appHTTPSClosed layers: requests may not skip themNot allowedpresentation callingpersistence directly;any call upwards
Calls flow downwards only; closed layers cannot be skipped, while the open services layer can be.

How it works: closed layers, open layers and isolation

Each layer can be closed or open. A request must pass through a closed layer: the presentation layer may not reach past the business layer into the persistence layer, even when that would be quicker to write. An open layer may be bypassed. The usual example is a layer of shared technical services, such as sending emails, generating PDF receipts or writing audit records. Only some business operations need them, so forcing every request through that layer would add pointless forwarding code. In the diagram, the business layer may call the services layer or go straight past it to persistence.

Closed layers give layers of isolation. Because the presentation layer knows only the business layer's interface, the team can replace the object-relational mapper, restructure the tables or move from one database product to another without touching a single controller or template, as long as the business layer's interface stays the same. That is information hiding (David Parnas, 1972) applied to a whole tier of the code, and it is the main benefit the style offers. Every open layer, and every exception that lets one layer skip another, weakens it: if controllers query the database directly, a schema change now ripples into the presentation layer too.

The direction of dependency is the other half of the rule. Lower layers must not know about higher ones: the persistence layer must never import a controller, and the business layer must never build HTML. When a lower layer needs to notify a higher one, it does so through an abstraction the lower layer owns, such as a callback interface or an event, so that the dependency still points downwards in the code. Section 3.4 covers dependency direction, cycles and strict versus relaxed layering in depth.

It helps to keep layers and tiers apart. A layer is a logical grouping in the code. A tier is a physical boundary at run time, such as a separate server or process. A classic three-tier system runs the browser, an application server and a database server as three tiers, while the application server alone may contain three or four layers. Turning a layer into a tier puts a network between two layers that used to call each other in memory, which multiplies the cost of every call; this is rarely worth doing for its own sake.

Example: a classic web application

Consider the ordering website of a regional food-delivery company: customers browse menus, place orders and pay; restaurant staff see incoming orders. One team of five builds it with a mainstream web framework and a PostgreSQL database. A layered structure is the obvious starting point and the code layout makes the layers visible.

foodshop/
  presentation/          # HTTP only: parse requests, render responses
    order_views.py
    menu_views.py
    templates/
  business/              # rules and use cases; no HTTP, no SQL
    order_service.py
    pricing.py
  services/              # open layer: shared technical services
    email.py
    receipts.py
  persistence/           # all database access
    order_repository.py
    menu_repository.py
    models.py
  settings.py
Package structure of the layered food-ordering application: one top-level package per layer.

Worked example

Worked example: following one request down the layers

A customer presses Place order. The request arrives as POST /orders with the basket and delivery address.

Presentationorder_views.place_order parses the JSON, checks it is well formed and that the user is signed in, and calls order_service.place(customer_id, items, address). It knows nothing about tables or prices.
Businessorder_service.place checks the restaurant is open, prices the basket with pricing.py (discounts, delivery fee, tax), and asks order_repository to save the order. It calls receipts.send in the open services layer to email a receipt.
Persistenceorder_repository.save maps the order to rows in orders and order_lines and writes them in one database transaction.
DatabasePostgreSQL commits the transaction and returns. The result flows back up: the service returns the new order, the view renders 201 Created with the order number.
Change requestProduct asks for a courier tip. The developer adds a tip field to the request and response (presentation), a maximum-tip rule and the tip in the total (business), and a column and mapping (persistence), plus a migration (database). Four layers for one small feature.

Each layer did one kind of job, and each could be tested alone with the layer below replaced by a test double.

The tip shows the cost of technical partitioning: even a small business change cuts across every layer, so it touches more files, more reviewers and, if teams are split by layer, more teams.

The consequences of technical partitioning

Because layering is technical partitioning, the business domain is smeared across the layers. There is no single place called delivery: there are delivery controllers, delivery services and delivery repositories, interleaved with everything else in each layer. A developer who wants to understand how tipping works must read across all of them. As the system grows, each layer becomes a large, low-cohesion collection of unrelated code: the business layer contains ordering, payment, menu and courier rules side by side, and nothing stops the payment code from calling deep into the menu code. Over time the layers stay clean but the inside of each layer becomes a big ball of mud.

There is an organisational cost too. Organising teams by layer (a front-end team, a back-end team, a database team) means nearly every feature needs a hand-off and a queue between teams, which Conway's law then cements into the architecture. Martin Fowler calls teams organised by layer an anti-pattern for this reason: developers need not be full-stack, but teams should be. A layered architecture does not force teams to be split by layer, but it makes it the path of least resistance.

Finally, a layered monolith is one architecture quantum: one deployment unit and usually one database. Every change, however small, means building, testing and redeploying the whole application, and the whole application scales and fails as one. That is often perfectly acceptable, but it sets a ceiling.

The architecture sinkhole and other anti-patterns

The architecture sinkhole anti-pattern, a name popularised by Mark Richards, describes requests that flow through layers which do nothing but pass them on. Viewing a menu is the typical case: the controller calls a service method that calls a repository method that runs a query, and the controller and service add nothing. Every layer of pass-through adds code to write, test and read, and a little latency. Some pass-through is the price of isolation and is fine. Richards offers a rule of thumb: if roughly a fifth of requests are simple pass-through, that is normal; if most of them are, the application is probably mostly data access with little business logic, and layering is the wrong style or the layers should be opened. Opening layers, however, gives up isolation, so decide deliberately and record why.

Other common anti-patterns are worth recognising early. Leaky layers: SQL in controllers, HTTP request objects passed into services, or ORM entities rendered straight to JSON, so a change in one layer ripples into others. Upward dependencies: a repository that calls a service, or a domain object that formats HTML. The anaemic business layer: services that only shuffle data while the real rules live in controllers or stored procedures; Fowler named the related Anemic Domain Model anti-pattern in 2003. The god service: one OrderService of several thousand lines that every other service calls. The utility layer that everything depends on: a common package that gradually accumulates business logic and couples everything to everything.

Sinkhole versus useful layering

Two requests through the same three layers: viewing the menu passes straight through the controller and service doing nothing, a sinkhole; placing an order does real work in every layerRequest A: view menuController: passService: passRepository: queryOnly the last layer does workRequest B: place orderController: validate inputService: price, check rulesRepository: save in transactionEvery layer adds behaviour
Pass-through requests cost code and latency without adding behaviour; a few are normal, a majority is a warning sign.

Characteristics of the layered style

CharacteristicRatingReason
SimplicityHighFamiliar to almost every developer and the default structure of most frameworks; easy to explain and start.
Overall costHigh (cheap)One code base, one deployment, one database; minimal infrastructure and operations.
TestabilityMediumEach layer can be tested with the layer below replaced by a test double, but realistic tests often need the whole stack and a database.
Modifiability for business changeLow to mediumTechnical changes stay inside a layer, but most feature changes cross every layer.
DeployabilityLowAny change means redeploying the whole application; release risk grows with size.
Scalability and elasticityLowThe whole application scales as one unit; you cannot scale only the busy part, and start-up of a large application slows elastic scaling.
Fault toleranceLowOne memory leak or runaway query can take down the whole process; there is no isolation between features.
PerformanceMediumIn-process calls are fast, but extra layers and object mapping add overhead, and sinkholes add more.
Evolvability at scaleLowLow cohesion inside each layer and coupling across features make large layered systems hard to restructure.

When it is a good fit, and when it is not

Layered architecture is a good choice for small and medium applications built by one team, for products whose domain is not yet well understood (domain partitioning needs boundaries you do not yet know), for tight budgets and deadlines, and for applications that are mostly forms over data, with modest rules. It is also a reasonable inner structure for a single module or service that is itself part of a domain-partitioned system: a layered delivery module inside a modular monolith gets the best of both.

It is a poor choice as the top-level structure of a large system with several teams, because every team works in every layer and features collide. It is poor when parts of the system need very different scalability or availability, because there is only one quantum. And it is poor when the business logic is rich and central, because the rules are scattered among controllers, services and stored procedures; there, a domain-centred inner structure such as hexagonal or clean architecture keeps the core independent of the database and the web framework.

The most common evolution path is from a layered monolith to a modular monolith: keep one deployment, but reorganise the top level by domain and keep layers inside each module.

Enforcing the layer rules in code

Layer rules decay quickly. Under deadline pressure, a developer imports the repository straight into a controller because it saves twenty minutes, nobody notices in review, and the next developer copies the pattern. The defence is an architecture test: an automated check of dependencies that runs in the build and fails it when a rule is broken, which makes the rule a fitness function.

In Python, import-linter reads contracts from a configuration file. Its layers contract lists layers from highest to lowest and forbids any lower layer from importing a higher one. It does not on its own make layers closed (a higher layer may import any lower layer), so a forbidden contract adds the rule that presentation must not import persistence directly; it needs allow_indirect_imports switched on, because otherwise the normal chain from presentation through business to persistence would count as a violation. In Java, ArchUnit expresses the same rules as a unit test: layeredArchitecture() declares the layers by package and states, for example, that the persistence layer may only be accessed by the service layer. Similar tools exist for other ecosystems, such as dependency-cruiser for JavaScript and TypeScript.

Two practices make the checks stick. Introduce them with the existing violations recorded as known exceptions, so the build goes green immediately and only new violations fail it; then reduce the exceptions over time. And keep the configuration next to the code and review changes to it like code, because relaxing a rule is an architectural decision that deserves a decision record.

[importlinter]
root_package = foodshop

[importlinter:contract:layers]
name = Layers only depend downwards
type = layers
layers =
    foodshop.presentation
    foodshop.business
    foodshop.services
    foodshop.persistence

[importlinter:contract:closed-business]
name = Presentation must go through business
type = forbidden
source_modules = foodshop.presentation
forbidden_modules = foodshop.persistence
# presentation -> business -> persistence is the normal path
allow_indirect_imports = True
An import-linter configuration (.importlinter) for the food-ordering application: layers may only depend downwards, and presentation may not skip the business layer.

Check your understanding

What is the difference between a closed and an open layer?

A request must pass through a closed layer and cannot skip it; an open layer may be bypassed by the layer above, which is typical for a shared-services layer that only some requests need.

Why does a layered application tend to need changes in every layer for a new feature?

Because it is technically partitioned: the code for each business capability is split across presentation, business and persistence layers, and most features are defined in business terms.

What is the difference between a layer and a tier?

A layer is a logical grouping of code by responsibility; a tier is a physical run-time boundary such as a separate server. Several layers usually run inside one tier.

Why does an import-linter layers contract on its own not make the layers closed?

It only forbids lower layers from importing higher ones; a higher layer may still import any lower layer, so a separate forbidden contract is needed to stop presentation importing persistence.

Next · 5.3Modular monolithOne deployable application divided by business domain into modules with explicit APIs and their own data. How to enforce the boundaries, how modules communicate, what Shopify learned, and how it keeps the option of extracting services later.Continue →

Sources and further reading

Have a question about this section? ArchitectBot, the course’s AI study assistant, answers from the course first.

Ask: “Most of our requests just pass from controller to service to repository without doing anything. Should we remove the service layer?”