Layers
In short: Layered architecture splits an application into presentation, application, domain and data, with dependencies pointing only downwards, so a business rule lives in one place.
Problem
The discount at the checkout and in the report
The shop gives 10% off a cart of €100 and more. The checkout works out the discount in the controller that handles the request. The report for accounting is an SQL query on the same table, with its own condition in WHERE:
# The checkout: the controller reads the cart and works out the discount itselfclass CheckoutController: def __init__(self, carts): self.carts = carts
def post(self, customer): total = self.carts[customer] if total >= 100: total = total - total // 10 return f"{customer} pays €{total}"
# The report is an SQL query on the same table:# SELECT customer FROM carts WHERE subtotal > 100# Here the table is a dictionary and WHERE is an if.def discounted(carts): return [customer for customer, subtotal in carts.items() if subtotal > 100]carts = {"Maria": 100, "Ivan": 150, "Petya": 80}checkout = CheckoutController(carts)for customer in carts: print("Checkout: " + checkout.post(customer))print("Report: discounted are " + ", ".join(discounted(carts)))Checkout: Maria pays €90Checkout: Ivan pays €135Checkout: Petya pays €80Report: discounted are IvanMaria pays €90, and the report says she had no discount. The checkout has >= 100, the query has > 100. Accounting sees a €10 difference and nobody knows which version is right.
There is a second pain. The rule is glued to the controller: to check whether >= 100 is right, you need a request, a cart in a table and an answer to read. The mobile app will want a third version.
The idea
Four layers and one direction
In the lesson Repository you saw a controller, a service and a repository one below the other. Here we arrange a whole application that way and give it a rule. Layered architecture splits the code into four layers:
- presentation: the controller. It reads the request and shows the answer. It does not calculate;
- application: the service. It knows the steps of one task ("get the cart, work out the price"), but not the rules;
- domain: the rules of the shop.
Pricing.totalknows "€100 and more is 10% off" and nothing else: no requests, no tables; - data: the repositories. Where the cart comes from.
Layers have one rule: dependencies point down. The upper layer calls the lower one, and the lower one does not know the upper one exists. That is why the domain can be used by the checkout, the report and the mobile app without being touched.
A layer is not necessarily a class. In a big project a layer is a package or a project with many classes in it.
See it
The request through the layers
The four layers of the checkout, from the top down:
# Presentation: the request comes in, the answer goes outclass CheckoutController: def __init__(self, service): self.service = service
def post(self, customer): total = self.service.checkout(customer) return f"{customer} pays €{total}"
# Application: the steps of one taskclass CheckoutService: def __init__(self, carts): self.carts = carts
def checkout(self, customer): subtotal = self.carts.subtotal_of(customer) return Pricing.total(subtotal)
# Domain: the rule of the shop and nothing elseclass Pricing: @staticmethod def total(subtotal): if subtotal < 100: return subtotal return subtotal - subtotal // 10
# Data: where the cart comes fromclass CartRepository(Protocol): def subtotal_of(self, customer: str) -> int: ...CartRepository is a Protocol: it describes what a repository must have, but nobody inherits from it. Python accepts any object that has subtotal_of, and a type checker (mypy) can say early if it is missing.The repository in memory, and the report, which now asks the domain instead of writing its own condition:
class InMemoryCarts: def __init__(self): self.subtotals = {}
def put(self, customer, subtotal): self.subtotals[customer] = subtotal
def subtotal_of(self, customer): return self.subtotals.get(customer, 0)# Application: the report asks the domain, it has no rule of its ownclass DiscountReport: def __init__(self, carts): self.carts = carts
def discounted(self, customers): names = [] for customer in customers: subtotal = self.carts.subtotal_of(customer) if Pricing.total(subtotal) < subtotal: names.append(customer) return namesPutting it together: the checkout and the report over the same data.
carts = InMemoryCarts()carts.put("Maria", 100)carts.put("Ivan", 150)carts.put("Petya", 80)customers = ["Maria", "Ivan", "Petya"]
web = CheckoutController(CheckoutService(carts))for customer in customers: print(web.post(customer))report = DiscountReport(carts)print("Discounted: " + ", ".join(report.discounted(customers)))Maria pays €90Ivan pays €135Petya pays €80Discounted: Maria, Ivan# Presentation: the request comes in, the answer goes outclass CheckoutController: def __init__(self, service): self.service = service
def post(self, customer): total = self.service.checkout(customer) return f"{customer} pays €{total}"
# Application: the steps of one taskclass CheckoutService: def __init__(self, carts): self.carts = carts
def checkout(self, customer): subtotal = self.carts.subtotal_of(customer) return Pricing.total(subtotal)
# Domain: the rule of the shop and nothing elseclass Pricing: @staticmethod def total(subtotal): if subtotal < 100: return subtotal return subtotal - subtotal // 10
# Data: where the cart comes fromclass CartRepository(Protocol): def subtotal_of(self, customer: str) -> int: ...# Presentation: the request comes in, the answer goes outclass CheckoutController: def __init__(self, service): self.service = service
def post(self, customer): total = self.service.checkout(customer) return f"{customer} pays €{total}"
# Application: the steps of one taskclass CheckoutService: def __init__(self, carts): self.carts = carts
def checkout(self, customer): subtotal = self.carts.subtotal_of(customer) return Pricing.total(subtotal)
# Domain: the rule of the shop and nothing elseclass Pricing: @staticmethod def total(subtotal): if subtotal < 100: return subtotal return subtotal - subtotal // 10
# Data: where the cart comes fromclass CartRepository(Protocol): def subtotal_of(self, customer: str) -> int: ...class InMemoryCarts: def __init__(self): self.subtotals = {}
def put(self, customer, subtotal): self.subtotals[customer] = subtotal
def subtotal_of(self, customer): subtotal = self.subtotals.get(customer, 0) # the report wanted the sum after the discount return Pricing.total(subtotal)Step by step, in words
- Four layers: presentation, application, domain, data. Each one calls only the layers below it: every arrow points down.
- The checkout request for Maria comes into the controller. It only reads the request and shows the answer.
- The controller hands the task to the application layer: the service knows the steps of the checkout.
- The service asks the data layer for the cart: €100. The repository gives data and holds no rules.
- Then it asks the domain: Pricing knows the rule "€100 and more is 10% off". €100 becomes €90.
- The answer climbs back to the controller, and it prints the line. The report asks the same Pricing.
- Now someone makes the repository return the sum after the discount. The data layer calls Pricing: an arrow pointing up.
- Ivan's cart: the repository gives €135, and the service takes 10% off again. He pays €122 instead of €135.
- Down is allowed, up is the bug: the rule leaked back into the data, as in the SQL query of the report.
Maria pays €90 and is in the report, because both places ask the same rule. The service calls the repository without going through the domain: that is allowed, because the repository is below it. The rule is only about direction.
Try it
The rule with nothing around it
The domain knows nothing about controllers and repositories, so it is checked on its own: no request, no cart, no database. Its test looks like that too, as in the lesson Tests with unittest.
What does the program print?
for subtotal in [99, 100, 101]: print(f"€{subtotal} → €{Pricing.total(subtotal)}")The boundary is included: €100 already gets the discount. From €101, €10 comes off, because the division is an integer one and 101 / 10 is 10. These very boundaries (99, 100, 101) are the first test of any rule about money.
Try it
Find the bug
The report wants the sum after the discount. Someone decides the easiest way is for the repository to give it ready-made:
class InMemoryCarts: def __init__(self): self.subtotals = {}
def put(self, customer, subtotal): self.subtotals[customer] = subtotal
def subtotal_of(self, customer): subtotal = self.subtotals.get(customer, 0) # the report wanted the sum after the discount return Pricing.total(subtotal)The checkout and the report are the same, without a single changed line:
carts = InMemoryCarts()carts.put("Maria", 100)carts.put("Ivan", 150)carts.put("Petya", 80)customers = ["Maria", "Ivan", "Petya"]
web = CheckoutController(CheckoutService(carts))for customer in customers: print(web.post(customer))report = DiscountReport(carts)print("Discounted: " + ", ".join(report.discounted(customers)))Maria pays €90Ivan pays €122Petya pays €80Discounted: IvanIvan pays €122 instead of €135, and Maria is missing from the report again. Which line is the bug on?
Pick the line with the bug.
The data layer calls the domain: an arrow pointing up. The repository now knows the rule, and it is applied twice: €150 → €135 in the repository, then €135 → €122 in the service. The report asks "is €90 reduced?" and says "no" for Maria. It is the same problem as WHERE in the SQL query at the start: a rule that lives in the data. The repository should return only what it stores. The sum after the discount is the job of the service, which asks Pricing.
The idea
By technique or by feature
Layers say who calls whom. How the files are arranged is a separate question. The first way is by technique: one folder for each layer.
shop/ web/ checkout.py catalog.py services/ checkout.py catalog.py domain/ pricing.py product.py data/ carts.py products.pyThe second is by feature (vertical slice): one folder for each part of the shop, with the layers inside it.
shop/ checkout/ views.py service.py pricing.py carts.py catalog/ views.py service.py product.py products.pyBy technique is easy at the start, but every change to the checkout touches four folders, and the service/ folder grows with everything. By feature the change is in one place, and each folder can have its own rules about who calls it from outside. That is the step towards the modules in the lesson Monolith, modular monolith, services. The rule about direction holds in both cases.
The idea
When layers hurt
Layers cost files and calls. They are not worth it when:
- there are no rules. A form that only saves to the database goes through a controller, a service and a domain that only pass things on. This is called a sinkhole: the request falls through the layers and nobody touches it;
- every layer has its own object for the same data:
CartDto,CartModel,CartEntity, and converting between them is half of the code; - everything is arranged by technique and the project is big: the
service/folder has 200 files that have nothing to do with each other.
They are worth it when there are rules that must be kept in one place and checked without a database, and when the same rule is called from several places: the checkout, the report, the mobile app.
More precisely, if you are curious
Strict and relaxed layers. With strict layers each layer calls only its neighbour. With relaxed ones (as in this lesson) it may skip down: the service calls the repository directly. Most projects are relaxed. The strict rule gives more passing-on and rarely helps.
In frameworks. Spring puts the layers in annotations: @Controller, @Service, @Repository. ASP.NET Core has controllers, and services and repositories are registered in the DI container. Django is arranged by feature: every "app" has its own views.py and models.py. Nest (Node) groups a controller and its services into a module per feature, and Express imposes nothing. No framework guards the direction by itself: a @Repository that calls a @Service compiles.
Guarding the direction. The direction can be checked automatically: ArchUnit in Java, ArchUnitNET or NetArchTest in C#, import-linter in Python and dependency-cruiser in JavaScript fail when a data package imports a domain package. You write a small check of this kind for modules in the lesson Monolith, modular monolith, services.
Money. In the lesson the sums are whole euros, so that the output is the same in all four languages. In a real shop money is in cents (long) or in Decimal, never in double.
What comes next. In the lesson Hexagonal and clean architecture the direction is turned all the way around: the domain does not depend even on the repository.
Exercise
Delivery in the domain
Solve it in your editor.
Delivery is €5 for an order under €50 and free from €50 up (the sum is after the discount). Put the rule in the domain, in Delivery.fee(total). A new controller shows the price and the delivery, and the service and the repository are not touched. At the end check the rule on its own, without a controller, for €49 and €50.
Maria pays €90 + delivery €0Petya pays €80 + delivery €0George pays €30 + delivery €5€49 → delivery €5€50 → delivery €0More exercises
- The mobile app. Add a second controller that returns the same sum as JSON text
{"customer":"Maria","total":90}. How many lines of the rule do you touch? - Discount per customer. Customers with a loyalty card get 15% from €100. Where does the card go: in the repository, in the domain or in the controller? Why?
- Checking the direction. Write a function that gets a list of "which layer calls which" (
data → domain,application → data) and prints every call that goes up.
Test
Check yourself
Related lessons
Section titled "Related lessons"- Builds on: Tests with unittest · Singleton and Dependency Injection · Repository · SOLID without dogma
- Continues in: Hexagonal and clean architecture · Monolith, modular monolith, services · Caching · ADR: architecture decision records
- Tasks on this topic: Software architecture