LayersSCALE-SPECIFICGENERALFRAMEWORK-SPECIFIC

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.

What actually happensHow to build it

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.

The question

If layered architecture is not the only option, what are the real alternatives and what does each optimise for?

The requirement

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.

The obvious build

Pick the one that is most widely written about, apply it everywhere, and treat disagreement as inexperience.

Why it breaks

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.

How it breaks in production
  • 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.
  • 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.
RequirementAPI ContractApplication LogicData AccessExternal DepsConcurrencyFailureSecurityObservabilityDeploymentScale

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.

How should this codebase be organised?

What kind of change do you make most often, and who makes it?

Layered / technical folders

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.

Vertical slices / feature folders

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.

Transaction script

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.

Hexagonal / ports and adapters

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.

Mixed, with a stated criterion

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

GENERALThe operations are language-independent; only the file extensions change. What is *not* general is the weighting — which row matters most is set by your team and your change log.

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 weeklyTechnical layersVertical slicesTransaction scriptHexagonal
Add a field to one featureTouches 4-5 dirsOne dirOne file2-3 dirs (domain + adapter)
Read a feature end to endJump between dirsOne dir, top to bottomOne fileFollow the port to the adapter
Delete a feature completelyGrep and hopeDelete the directoryDelete the fileDelete slice of domain + adapters
Share a rule across featuresNatural home existsNeeds a deliberate shared moduleNo home — it gets copiedDomain layer, natural home
Swap an external dependencyOne directoryEvery slice that used itEvery script that used itOne adapter — the design goal
Test business rules with no databasePossible if direction heldDepends on the sliceUsually notDesigned for it
Two teams working without conflictsEveryone edits every dirA dir per teamFine while smallShared domain is contested
Onboard someone in week oneFamiliar layoutFeature is findable by nameTrivially readableVocabulary 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

Failure modes
  • 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.
What can race
  • 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).
Security
  • 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).
Misreads
  • "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

How you see it in production
  • 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.
What changes at 10x and 100x
  • 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).
What this costs
  • 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.

API Designapi-ownership
Domains that do not exist yet
  • 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.