Where layering leaks
Layered architecture leaks through three failure modes: the transaction-script trap (procedures, not layers), layer-skipping (presentation reaches into infrastructure), and the fat service god object. Each leak is a force that motivated hexagonal and clean architecture.
Eighteen months into the B2B order platform, a senior engineer audited the codebase. The four-layer structure was in place — the packages were named correctly. But OrderController had grown to 900 lines because engineers added quick logic there rather than risk touching the service. OrderService had 61 methods and imported directly from PostgresOrderRepository, StripeClient, SendGridClient, PDFGenerator, and S3Client — the service layer had become the integration layer, not the orchestration layer. And there was a getBillingReportData() method in OrderController that called OrderRepository.findRawSql() directly, because the developer who wrote it needed a specific join that no service method exposed. The layers existed as package names. The discipline that makes layers valuable — keeping each layer’s concerns distinct and its imports bounded — had eroded entirely. The architecture was layered in structure and a big ball of mud in practice.
Leak 1: The transaction-script trap
The transaction-script trap is what happens when layering is applied structurally but the team’s mental model stays procedural. Each use case becomes a long procedure in a service method — step 1, step 2, step 3, if this then that — with the domain objects as passive data containers that the procedure manipulates.
// Transaction script masquerading as a layered architecture
class OrderService {
async confirmOrder(orderId: string, userId: string): Promise<void> {
// Step 1: load
const order = await this.orderRepo.findById(orderId);
const user = await this.userRepo.findById(userId);
const invoices = await this.invoiceRepo.findByOrderId(orderId);
// Step 2: validate (all logic inline, none in the domain)
if (order.status !== 'pending') throw new Error('Not pending');
if (!user.hasRole('manager')) throw new Error('Unauthorized');
if (invoices.some(i => i.status === 'overdue')) throw new Error('Overdue invoices');
if (order.totalAmount > 10000 && !order.approvalId) throw new Error('Needs approval');
// Step 3: mutate (directly setting fields)
order.status = 'confirmed';
order.confirmedAt = new Date();
order.confirmedBy = userId;
// Step 4: side effects (service directly calls infrastructure)
await this.orderRepo.save(order);
await this.emailClient.send(order.customerEmail, 'Order confirmed', ...);
await this.eventBus.publish({ type: 'OrderConfirmed', orderId });
}
}The layers exist but the logic lives in one long procedure inside the service. Testing this method requires a real database (or mocking 5 dependencies). Adding a new rule means editing the procedure. The domain layer has no behavior — the service is both the application layer and the domain layer simultaneously.
This is the Transaction Script pattern masquerading as layered architecture. The service package and the domain package are both technically present — but the domain package is empty of real logic, and the service package has absorbed everything.
Leak 2: Layer-skipping and leaky abstractions
Layer-skipping occurs when a higher layer imports from a lower layer, bypassing the intermediate layers. The canonical violation: a controller that imports a repository class directly.
// Controller importing infrastructure directly
import { PostgresOrderRepository } from '../infra/PostgresOrderRepository';
class BillingReportController {
private repo = new PostgresOrderRepository();
async getBillingReport(req: Request): Promise<Response> {
// Direct SQL in the controller
const rows = await this.repo.findRawSql(`
SELECT o.id, o.total_amount, i.invoice_number
FROM orders o JOIN invoices i ON i.order_id = o.id
WHERE o.status = 'confirmed' AND o.confirmed_at > $1
`, [req.query.since]);
return Response.json(rows);
}
}This is the getBillingReportData problem from the opening story. The developer needed a specific SQL join that no existing service or domain method exposed. Rather than add a method to the appropriate layer, they reached directly into infrastructure.
The leak is self-reinforcing. Once one controller imports a repository, other developers see the pattern and repeat it. Within months, controllers throughout the application have direct repository dependencies. The infrastructure layer is now directly coupled to the presentation layer — swapping the database requires changing controllers.
Leaky abstractions are a subtler form of the same failure: the abstraction boundary nominally exists, but implementation details from a lower layer bleed through it. A domain service that returns a Knex.QueryBuilder object instead of a domain type leaks the query builder into the layer above. A repository interface that exposes an EntityManager parameter leaks the ORM into the application layer. The caller now depends on implementation details even though there is an apparent interface boundary.
▸Why this works
Why does layer-skipping happen? The immediate cause is pressure: a deadline, a one-off reporting need, a complex query that is faster to write in SQL than to express through domain methods. The underlying cause is that layered architecture has no enforcement mechanism. There is no compiler error when a controller imports a repository. The only enforcement is discipline, code review, and team convention — all of which degrade under pressure. This is the structural gap that hexagonal architecture addresses: by making the domain own its ports (interfaces), it makes the correct dependency direction the path of least resistance, not an opt-in convention.
Leak 3: The fat service and the god object
The fat service is the end state of an application where all business logic gravitates to the service layer. It starts with reasonable methods — createOrder, confirmOrder, cancelOrder — and grows as features accumulate. After 18 months, OrderService has 61 methods and imports from 11 different dependencies.
The fat service has the structural properties of a god object:
- It knows about everything: infrastructure clients, domain entities, external services, reporting queries, notification logic, audit logging.
- Everything depends on it: presentation layer, background jobs, batch processors, integration adapters.
- It is untestable in isolation: instantiating
OrderServicerequires mocking 11 dependencies. A test forconfirmOrdermust configure the full dependency tree. - Changes ripple everywhere: modifying
OrderServicecan affect any of its 61 callers.
The fat service is the result of a correct layering instinct — keep business logic out of controllers — combined with the absence of a domain layer that can absorb that logic. Without a rich domain model to hold invariants and entities, and without clear use-case boundaries in the application layer, the service layer becomes the catch-all.
// What a fat OrderService imports after 18 months:
import { OrderRepository } from '../infra/OrderRepository';
import { InvoiceRepository } from '../infra/InvoiceRepository';
import { UserRepository } from '../infra/UserRepository';
import { StripeClient } from '../infra/StripeClient';
import { SendGridClient } from '../infra/SendGridClient';
import { PDFGenerator } from '../infra/PDFGenerator';
import { S3Client } from '../infra/S3Client';
import { EventBus } from '../infra/EventBus';
import { AuditLogger } from '../infra/AuditLogger';
import { ReportingQueryRunner } from '../infra/ReportingQueryRunner';
import { FeatureFlagClient } from '../infra/FeatureFlagClient';The service layer has become the integration layer. It is not orchestrating domain logic — it IS the domain logic, mixed with infrastructure calls. Extracting a single use case (say, order confirmation) requires untangling it from 61 other methods sharing the same dependency tree.
▸lesson.inset.note
The three leaks are not independent failures — they compound each other. The transaction-script trap hollows out the domain layer, so the fat service absorbs the logic the domain should hold. The fat service accretes more and more infrastructure dependencies, making it tempting to skip through it to reach those dependencies directly. Layer-skipping then creates tight coupling between presentation and infrastructure. The result is that all three failure modes arrive together in a mature codebase that started with good architectural intentions.
Why these leaks motivated hexagonal and clean architecture
Each of the three leaks points to a structural gap in flat n-tier layering:
The transaction-script trap points to the gap: no mechanism forces the domain layer to contain real logic. A flat four-layer stack does not prevent a service from absorbing all behavior.
Layer-skipping points to the gap: no mechanism prevents higher layers from importing lower layers. The dependency rule is a convention, not a structural constraint. Nothing in a plain package structure stops a controller from importing a repository.
The fat service points to the gap: no mechanism defines the boundary of a use case. A service class can grow without limit, absorbing concerns that should be in separate components.
Hexagonal architecture (unit 04) addresses all three by inverting the dependency at the domain boundary. The domain owns its ports — the interfaces it needs from the outside world. No infrastructure class can be imported by the domain because the domain defines the interface and infrastructure implements it. The service (application layer) is bounded by its primary port, not by a class that happens to live in a “services” package. The inside of the hexagon (domain + application) is testable without any infrastructure at all, because the infrastructure is always behind an interface the domain owns.
Clean architecture (unit 05) generalizes this to concentric layers with the same dependency rule: no inner layer imports any outer layer. The entity core, the use-case ring, the interface adapters, and the frameworks/drivers are separated by hard dependency constraints — not by package naming conventions.
The team's OrderService has grown to 80 methods and imports from 12 infrastructure classes. A new engineer proposes splitting it into OrderCommandService (writes) and OrderQueryService (reads). Does this fix the fat service problem, and why or why not?
A repository interface IOrderRepository is defined in the domain layer. Its only method is `findByCustomerAndDateRange(customerId: string, from: Date, to: Date): Promise<Order[]>`. The PostgreSQL implementation constructs a complex query using multiple JOINs. Is this a leaky abstraction?
The team decides to adopt hexagonal architecture to fix the three layering leaks. How does hexagonal architecture structurally prevent each of the three leaks discussed in this lesson?
- 01What is the transaction-script trap and how does it differ from a legitimate use of the Transaction Script pattern?
- 02What is layer-skipping, and why does it self-reinforce once it appears?
- 03What are the three structural gaps in flat n-tier layering that the three leaks expose, and what architectural response does each motivate?
Layered architecture leaks through three predictable failure modes, each exposing a structural gap in the flat four-layer model.
The transaction-script trap: the service layer absorbs all business logic as long procedures while the domain layer stays empty. The layers exist by name but not by concern separation. Domain objects are passive data bags with no enforced invariants. Testing requires the full dependency tree.
Layer-skipping: higher layers bypass intermediate layers and import from lower ones directly. A controller imports a repository because the query it needs was faster to write in raw SQL than to expose through a domain method. Once one skip appears, it normalizes. The infrastructure ends up coupled to the presentation, defeating the replaceability layering was supposed to provide. Leaky abstractions are a subtler form: interface boundaries exist but implementation details (query builders, ORMs, SQL row types) bleed through them.
The fat service: when the domain is hollow and use-case boundaries are undefined, the service layer becomes the catch-all. After 18 months it has 60 methods and 11 infrastructure imports. It is a god object disguised as a service layer — untestable in isolation, risky to modify, and impossible to extract a single use case from.
Each leak names a force that motivated the next generation of structural patterns. Hexagonal architecture (unit 04) inverts the dependency at the domain boundary: the domain owns its ports, infrastructure implements them, and the core is testable with no infrastructure. Clean architecture (unit 05) generalizes this to concentric layers with a hard dependency rule enforced by structure, not convention. Both exist because flat layering’s discipline degrades under the pressure of deadlines and growing codebases.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.