Alternatives to Layering
Vertical slices, transaction scripts and hexagonal are not lesser versions of layering — they optimise for different changes, and one of them probably matches yours.
The requirement, the obvious build, and why it breaks
Every lesson starts where the work starts: someone asked for something, and the first implementation that comes to mind has a problem.
If layered architecture is not the only option, what are the real alternatives and what does each optimise for?
The team is arguing about folder structure for the third time this quarter. Someone wants controllers/services/repositories; someone wants a folder per feature; someone read about ports and adapters.
Pick the one that is most widely written about, apply it everywhere, and treat disagreement as inexperience.
Technical-role folders spread every feature across five directories. Adding a field to "invoices" touches controllers/, services/, repositories/, dtos/ and validators/, and a reviewer cannot see the feature in the diff.
- Technical-role folders spread every feature across five directories. Adding a field to "invoices" touches
controllers/,services/,repositories/,dtos/andvalidators/, and a reviewer cannot see the feature in the diff. - Deleting a feature becomes archaeology, because its parts are interleaved with five other features in every folder. Dead code accumulates because removing it is risky.
- A hexagonal structure applied to a CRUD service produces a port and an adapter per table and a domain model identical to the row, and the team quietly stops using it.
- A transaction-script codebase that grew past its intended size has the same 400-line procedure copied with variations, and no place to put a shared rule.
- The structure chosen for a service that "will become microservices" makes every change slower for the eighteen months before that never happens.
What is actually happening
- Every structure is a bet about what changes together. Layering bets that changes are technical — swap a database, add a transport. Vertical slices bet that changes are by feature — most tickets name one feature.
- Layered / technical folders: group by role. Optimises for reuse of a role across features and for onboarding people who already know the convention.
- Vertical slices / feature folders: group by feature; each folder holds its own handler, logic, queries and types, layered internally if it wants to be. Optimises for change locality and for deleting things.
- Transaction script: one procedure per operation, top to bottom, doing its own data access. Optimises for readability of a single operation and minimum indirection.
- Hexagonal / ports and adapters: the application defines interfaces (ports); every external thing is an adapter. Optimises for substitutability at the edges and for testing the core without infrastructure.
- These are combinable, which the arguments usually miss. Vertical slices with a layer rule inside each slice is a common, unremarkable and effective arrangement.
- What they do *not* differ on: none of them changes concurrency, transactions, failure handling or performance. Structure is about change cost.
Four structures, and the change each one makes cheap
The honest comparison is not which is best but which change each makes cheap and which it makes expensive. Every one of these is running in production somewhere at large scale, which is the strongest available evidence that none of them is disqualifying.
Read the cost column first. The costs are what teams actually experience; the benefits are what they expected.
What kind of change do you make most often, and who makes it?
when A recognisable convention matters — high onboarding rate, contractors, or a framework that assumes it. Changes often affect one technical role across features.
cost Every feature is spread across five directories; a feature diff is unreadable; deletion is archaeology.
when Most pull requests name one feature; teams own features; you delete features as well as add them.
cost Genuinely shared rules need a deliberate home or they get duplicated; cross-slice calls need a stated policy or become a mesh.
when A small service, or an operation with little shared logic — an admin action, an import job, a webhook consumer.
cost No home for a shared rule; growth is by copy; there is no signal at the point where it stops fitting.
when Rich domain rules worth testing without infrastructure, or edges that genuinely change — two payment providers, an on-prem and a cloud storage backend.
cost An interface and an adapter per edge, often with one implementation; the indirection is paid whether or not the second implementation arrives.
when Different parts of the service have different change patterns — a rich pricing core and a wide CRUD surface.
cost Two conventions to learn and a criterion that must be written down and defended, or it decays into per-author preference.
The same feature, three shapes
Abstract comparison is unconvincing; the file tree is not. Here is one feature — invoices — in three of the arrangements, with nothing else changed.
Notice what each makes easy. In the technical layout, "show me everything about invoices" is five ls commands. In the slice layout, it is one directory and rm -r genuinely works. In the hexagonal layout, "what does this touch outside the process" is a single folder, and the domain folder can be tested with no container running.
TECHNICAL ROLES VERTICAL SLICES HEXAGONAL
src/ src/ src/
controllers/ features/ domain/
invoice.controller.ts invoices/ invoice.ts <- rules, no imports
order.controller.ts routes.ts pricing.ts
user.controller.ts place-invoice.ts ports/
services/ queries.ts invoice-store.ts <- interface
invoice.service.ts invoice.ts payment-gateway.ts
order.service.ts schema.ts clock.ts
user.service.ts README.md app/
repositories/ orders/ issue-invoice.ts <- use case
invoice.repository.ts ... adapters/
order.repository.ts users/ http/invoice-routes.ts
dtos/ ... postgres/invoice-store.ts
invoice.dto.ts shared/ stripe/payment-gateway.ts
validators/ db.ts system/clock.ts
invoice.validator.ts auth.ts
add a field: 5 files add a field: 1-2 files add a field: 2-3 files
delete feature: grep 5 dirs delete feature: rm -r delete feature: 3 dirs
swap Postgres: 1 dir swap Postgres: every slice swap Postgres: 1 adapter
shared rule: obvious home shared rule: needs a policy shared rule: domain/Judging a structure by what it makes expensive
The comparison that settles arguments is not a list of virtues; it is a list of operations with a cost attached. Every row below is something a team does regularly, and every structure is bad at at least one of them.
There is no column here without a weak cell, and that is the finding. Choosing a structure is choosing which weakness you would rather have.
| Operation you do weekly | Technical layers | Vertical slices | Transaction script | Hexagonal |
|---|---|---|---|---|
| Add a field to one feature | Touches 4-5 dirs | One dir | One file | 2-3 dirs (domain + adapter) |
| Read a feature end to end | Jump between dirs | One dir, top to bottom | One file | Follow the port to the adapter |
| Delete a feature completely | Grep and hope | Delete the directory | Delete the file | Delete slice of domain + adapters |
| Share a rule across features | Natural home exists | Needs a deliberate shared module | No home — it gets copied | Domain layer, natural home |
| Swap an external dependency | One directory | Every slice that used it | Every script that used it | One adapter — the design goal |
| Test business rules with no database | Possible if direction held | Depends on the slice | Usually not | Designed for it |
| Two teams working without conflicts | Everyone edits every dir | A dir per team | Fine while small | Shared domain is contested |
| Onboard someone in week one | Familiar layout | Feature is findable by name | Trivially readable | Vocabulary to learn first |
How to build it
Most important first.
- Choose from your change log, not from an article. Read the last fifty merged pull requests and count how many touched one feature, how many touched one technical role, and how many swapped an external dependency.
- Optimise for the change you actually make weekly. If forty of fifty PRs name one feature, slices are the structure that matches.
- Prefer structures that make deletion easy. A codebase where features can be removed cleanly stays smaller than one where they cannot (Feature Flags: Rollout, Kill Switches and Debt).
- Apply the strongest structure to the part that deserves it. Pricing and permissions can be hexagonal while the settings CRUD is a transaction script, in the same service.
- Whichever you pick, keep the one rule that survives all of them: logic must not import transport (Transport, Application, Domain, Infrastructure).
- Write down the criterion, not just the choice. "Feature folders, because most changes are feature-scoped" survives the next argument; "feature folders" does not.
What can go wrong
- Slices that duplicate a rule that is genuinely shared — tax calculation implemented three times because each slice owned "its" version.
- Slices that import each other freely, producing a mesh with feature-shaped names. Cross-slice calls need a policy: a stated public surface per slice, or events (Commands vs Events).
- Ports and adapters with exactly one adapter each, forever. The indirection is real, the substitutability is theoretical (When the Repository Is Just Indirection).
- Transaction scripts that grow past a screen and start sharing code by copy, so a fix lands in two of five copies.
- A migration between structures started and abandoned, leaving both, so a reader has to know two conventions and which files follow which.
- None of these structures changes what can race. A concurrency bug survives every refactor between them unchanged, which is worth saying because reorganising code often feels like fixing it (Backend Races).
- Structure decides how answerable a security question is. "Which code can start an outbound request?" is one grep in hexagonal, and a whole-codebase search in slices without a convention (SSRF — When the Backend Fetches a URL).
- Per-feature structures make it easy for one slice to forget the authorization check the others do. Cross-cutting enforcement should be structural — middleware, a policy module, or a check inside the shared query path — not per slice by habit (Where the Check Belongs).
- Any structure that lets a slice reach the database directly needs the tenant predicate enforced somewhere shared, or one slice will omit it (Tenant Isolation).
- "Clean architecture is the correct answer and the others are what you do before you learn it." It is one arrangement with one set of costs. Codebases that ship for a decade use all four.
- "Vertical slices mean no layers." Most slice codebases layer inside the slice. The two axes are independent.
- "Hexagonal is layering with different words." The distinction is real: hexagonal has no privileged direction between transports and infrastructure — both are adapters on the outside of the same core.
- "Transaction script is what beginners write." It is what a well-scoped small service should be, and it is a deliberate choice with a stated ceiling.
- "Pick one and apply it everywhere." Uniformity has value for navigation and none for correctness. A mixed codebase with a stated criterion is a normal outcome.
Operating it
- Measure files-touched-per-pull-request over time. A structure that matches your change pattern shows a low, stable number; a mismatched one shows a rising one.
- Measure how often two teams touch the same file. Rising co-editing is the signal to split modules along whatever axis is causing it (The Modular Monolith).
- Track the age of the oldest dead code path. A structure that makes deletion hard shows up here before it shows up in an argument.
- At 10x traffic: nothing changes. No structure in this lesson affects throughput.
- At 10x team size the axis of grouping becomes an ownership question. Feature folders give a team a directory; technical folders give every team a share of every directory, which is where merge conflicts and review bottlenecks come from.
- If a service is eventually split, slices are already split-shaped. That is a genuine advantage and still not a reason to split (Comparing Backend Architectures).
- Vertical slices trade some duplication for locality. That is often the right trade and is not free: genuinely shared rules need a deliberate shared home, or they get copied.
- Hexagonal trades indirection for substitutability. If nothing is ever substituted, you paid the indirection for a test-double convenience you could have got from a container (Test Against the Real Database).
- Transaction scripts trade structure for directness. They are excellent up to a size and there is no warning bell when you pass it.
- Technical layering trades locality for a familiar map. New hires orient quickly; every feature change is spread across the map.
Where this applies
Backend advice is context-sensitive. These labels say what each claim is specific to, and where a different stack or scale would differ.
- SCALE-SPECIFICFlips on team size and change pattern. Under about five engineers a transaction script or a flat feature folder is usually the fastest structure, and the ceremony of ports and adapters is pure overhead. Above about fifteen, ownership starts to dominate: feature-shaped folders give teams non-overlapping directories, while technical-role folders make every team edit every directory and turn review into a bottleneck. Traffic does not enter into it at any point.
- GENERALThe method — choose the structure that matches the changes you actually make — holds regardless of language, framework or size.
- FRAMEWORK-SPECIFICSome frameworks have a default and fighting it is expensive: Rails and Django are opinionatedly technical-role (
models/,views/,controllers/) with app/plugin boundaries as the feature axis; NestJS modules and Spring components are already feature-shaped containers; Express, FastAPI and Go impose nothing, so the choice is genuinely yours and genuinely unmade until someone makes it.
Where the depth lives
This domain teaches the application-side mechanism and hands the rest off.
- — System Design — choosing structure for a system of services rather than for the inside of one, where the same "what changes together" question is asked about deployables.