DecisionsGENERALFRAMEWORK-SPECIFICCONTESTED

Library or Framework

A library is something your code calls. A framework is something that calls your code. That inversion — not size, not scope — is what decides how much of your design it gets to make.

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 survives until the requirement changes.

The question

Which of these dependencies is deciding my application's structure, and did I agree to that?

The requirement

A new service needs HTTP routing, validation, persistence and background jobs. One proposal is a full-stack framework; the other is four libraries and a main function.

The obvious build

A framework is just a big library. Pick the one with the best documentation and the largest ecosystem.

Why it breaks

Size is not the difference and it obscures the one that matters. With a library, your code decides when things happen; with a framework, the framework owns the control flow and your code is what it calls (Dependency Inversion describes the mechanism, and here it is being applied to you).

How it breaks as requirements change
  • Size is not the difference and it obscures the one that matters. With a library, your code decides when things happen; with a framework, the framework owns the control flow and your code is what it calls (Dependency Inversion describes the mechanism, and here it is being applied to you).
  • That inversion is what makes a framework productive — the lifecycle, the wiring, the conventions are all decided — and it is also what makes it expensive to leave, because your code is shaped around being called.
  • The consequences show up in ordinary work: where does a transaction start, what happens when a request is cancelled, what runs before your handler. With libraries those are lines you wrote; with a framework they are documentation you have to trust (Local Reasoning).
  • And it is not a binary. Most frameworks are a set of libraries plus an inversion point, and how much of your code sits inside that inversion is a decision you can make deliberately rather than inherit.
RequirementConstraintsInvariantsResponsibilitiesBoundariesInterfacesStateDependenciesFailureImplementationTestsFeedbackEvolution

What limits the solution, and what must never stop being true

This domain leads with these two. A design that ignores its constraints is not a design, and an invariant nobody named is one nothing is protecting.

Constraints
  • The team of five has framework experience and two of them have never assembled a service from parts.
  • The service will live for years and will be handed to another team at some point.
  • The organisation has three other services, two of them on the framework.
Invariants
  • Business rules stay testable without starting an HTTP server or a database, whichever option is chosen.

Who owns what, and where the seams fall

Responsibilities decide boundaries; boundaries decide what an interface has to say.

Responsibilities
  • Your domain code owns the rules, and should not know what called it.
  • The framework owns the lifecycle: routing, request parsing, dependency wiring, transaction boundaries, error translation to HTTP.
  • You own the layer between them, and how thin it is determines how much of your codebase the framework has actually claimed (Boundary Adapters).
Boundaries
  • The boundary is the point of inversion: everything the framework calls is framework-shaped, and everything it calls in turn does not have to be.
  • A handler that parses a request, calls a domain function and formats a response is a boundary. A handler with the pricing rule inside it means the framework now owns the pricing rule (Designing by Responsibility).
  • The test for whether the boundary is real: can the domain function be called from a test, a job and a CLI without the framework present (Testing as Design Feedback).

Who calls whom

The distinction is not size or ambition. It is the direction of the call at the point where your code meets someone else's, and everything else follows from it: how you test, what your signatures look like, and what a migration costs.

Both snippets do the same job. The difference is which one gets to decide when things happen — and therefore which one your code has to be shaped for.

The same endpoint, on both sides of the inversion
Framework calls you, and the rule lives inside
@Controller('/orders')
class OrderController {
  @Post() @Transactional() @Validate(CreateOrderDto)
  async create(@Body() dto: CreateOrderDto, @Session() s: Session) {
    if (dto.total >= 50_000 && s.user.tier === 'gold')
      dto.shipping = 0                       // pricing rule, in a handler
    return this.repo.save(dto)               // framework entity as the model
  }
}
// cannot be called without the framework: not from a job, a CLI or a test
You call the library, and the rule stands alone
// domain/orders.ts — no framework types anywhere
export function placeOrder(cmd: PlaceOrder, customer: Customer): Order { /* rule */ }

// http/orders.ts — the only framework-shaped file
router.post('/orders', async (req, res) => {
  const cmd = parsePlaceOrder(req.body)
  const order = placeOrder(cmd, await customers.get(req.session.userId))
  await orders.save(order)
  res.status(201).json(toResponse(order))
})

The second version is longer and does the mapping by hand. What it buys is that placeOrder can be called by a test, a background job, a CLI backfill and an admin tool without any of them starting an HTTP server — and that a framework major version changes one file rather than the definition of an order. The first version is genuinely faster to write, and the decorators are doing real work; the cost is that the pricing rule now lives inside something the framework owns.

What the inversion does to your code

Drawing the control flow makes the practical consequences obvious. In the library arrangement, main is the top of the call stack and everything is reachable from anywhere. In the framework arrangement, the framework is the top, and anything it calls can only be reached through it.

This is why "can I call this from a script?" is such a reliable test. It is not a stylistic question — it is asking where the inversion point sits, and everything the framework calls is on the expensive side of any future migration.

  • Library — your code is the top of the stack. Replacing it changes your call sites, which are countable (Reversible and Irreversible Decisions).
  • Framework — it is the top of the stack. Replacing it changes the shape of everything it calls, which is not countable in advance.
  • The inversion point is a design decision, and drawing it deliberately is what keeps the framework's claim bounded (Architecture Boundaries).
  • Most frameworks are both — a set of libraries plus one inversion. Using the libraries without accepting the inversion everywhere is a legitimate middle position.
Two control flows for the same endpoint
you call itand you call thisso can anything elseit calls youmust go through the frameworkyour main()job / CLI / testhttp librarydomain ruleframework runtimeyour handler classdomain rule (inside)
UserLLMAgentToolDataDecisionHumanGuardrail

What the handler actually became

The cost of the inversion is easiest to see by writing out what a single controller class ends up knowing and how many unrelated things can force it to change.

Nothing in the list below is a mistake by the person who wrote it. Each item arrived for a good reason, and the result is a class whose reasons to change include the framework's release schedule.

responsibilitiesOrderController — decorated handler class in a full-stack frameworkThe controller, two years in
Knows
  • The HTTP shape of a create-order request, and its validation DTO
  • That gold-tier customers over 500 get free shipping (a pricing rule)
  • That this endpoint runs in a transaction, because of a decorator
  • The ORM entity, which is also the domain model and also the API response shape
  • Which framework version's decorator semantics it was written against
Does
  • Parses and validates input
  • Applies a pricing rule
  • Persists via the framework repository
  • Serializes the response, by returning an entity the framework knows how to render
Depends on
  • The framework runtime
  • The ORM
  • The session mechanism
  • A transaction manager it never names
Changes when — 5 distinct reasons
  • The API contract changes
  • The pricing rule changes
  • The database schema changes
  • The framework is upgraded across a major version and decorator behaviour shifts
  • Transaction boundaries need to move — which is a decorator here, so the change is invisible in the diff

Five unrelated reasons to change, one of which is a third party's release schedule. The repair is not to abandon the framework: it is to move the pricing rule and the domain shape out from under the inversion, leaving a handler whose only reasons to change are the API contract and the framework itself. That is two reasons, both legitimate, in a file whose job is to be framework-shaped (Single Responsibility, Critically).

How to build it

Most important first.

  • Decide how much of the application lives inside the inversion, and make that a conscious choice rather than the default of the tutorial.
  • Keep framework types out of domain signatures. A function taking a Request is a function that can only be called by the framework (Designing a Module Interface).
  • Use the framework for what it is genuinely good at — lifecycle, wiring, conventions the team shares — and stop there. Its ORM, its validation and its job runner are separable decisions that get made by default if you let them (What a Framework Charges).
  • Where a library will do, prefer it: a library you call is a decision you can reverse in an afternoon by calling something else (Reversible and Irreversible Decisions).
  • Be honest that conventions have real value for a team that changes membership. Five people who all know where things go is worth something a well-argued custom structure is not (Repository Structure).

What the next change costs

The field this whole domain exists for. A structure is only better if it makes the change after this one cheaper — and it is worth saying which changes it does not help.

Cost of the next change
  • With a thin boundary: switching HTTP frameworks costs the handler layer — a day or two per set of endpoints — because nothing beneath it knows what a request is.
  • Without: the framework's request, session and ORM types are in the domain, so switching is a rewrite, and the estimate for it is why it never happens (Stability and Dependency Direction).
  • The library assembly makes each individual swap cheap and makes onboarding more expensive: every new engineer has to learn a wiring scheme that exists nowhere else (Knowledge Sharing).
What the recommended approach costs
  • A thin boundary costs a mapping layer that does nothing but translate, which is real code with no business value, and reviewers will ask why it exists.
  • Refusing framework conventions costs the productivity they were bought for, and a team can end up paying the framework's cost while using none of its leverage.
  • Library assembly puts the wiring decisions on you, and those decisions are made once by whoever set it up and inherited by everyone after (Bus Factor).

What can go wrong

Failure modes
  • The framework's conventions become the architecture by default — a folder per technical layer, a model per table — and nobody ever decided that (Package by Layer).
  • The team assembles from libraries, gets no conventions, and after two years every module wires itself differently (Wiring and the Composition Root).
  • Domain logic migrates into framework hooks and lifecycle callbacks, where it is invisible to anyone reading the domain (Hidden Global State).
  • A "library-only" service quietly grows its own in-house framework, with all the inversion and none of the documentation or ecosystem (Speculative Generality).
Dependencies, and their direction
  • A library dependency points outward from your code: you call it, and replacing it means changing your call sites.
  • A framework dependency points inward: it calls you, so replacing it means changing the shape of everything it calls (Dependency Direction).
  • Both create version obligations, but only the framework creates one where a major upgrade can change your application's control flow (Semantic Versioning).
Misreads
  • "Frameworks are bad." Frameworks are how five people build a service in a week with conventions they share. The point is knowing which decisions you handed over (What a Framework Charges).
  • "Use a framework but keep it at arm's length from everything." Taken far enough this pays the full cost of the framework and gets none of the benefit — mapping layers everywhere and conventions used nowhere.
  • "Libraries mean no lock-in." A library with your data model expressed in its types is just as sticky as a framework. Inversion is one source of lock-in and coupling to a model is another (Leaky Abstractions).
  • "This is just dependency inversion." Related, and not the same: dependency inversion is a technique you apply to your own code, whereas this is about which side of that inversion you are on when someone else applies it to you (Dependency Inversion, Critically).

Testing it, and how it ages

What to test, and at which boundary
  • The clearest signal in this whole lesson: can you test the business rules without booting the framework? If every test needs a test client and a database, the inversion has reached the rules (What a Unit Is).
  • Test the boundary layer separately and thinly — that handlers map requests to calls correctly is a different test from whether the rule is right.
How this design ages
  • Frameworks have lifecycles you do not control: major versions, deprecations, and occasionally abandonment. That clock starts on the day you adopt (Deprecation).
  • Library assemblies drift toward an in-house framework as shared wiring accumulates. That is not automatically bad, but it should be recognised — an in-house framework has all the costs plus a documentation burden.
  • The part of the decision that survives longest is not the framework but how much of the codebase sits inside its inversion, because that determines what any future migration costs.

Where this applies

This domain's advice is contested more than most. These labels say what each claim is specific to — and where CONTESTED appears, the note gives the strongest form of the opposing view rather than a caricature.

  • GENERALThe inversion-of-control distinction is technology-independent, though it is sharpest in ecosystems with dominant full-stack frameworks and almost invisible in ones where assembling from libraries is the norm — the same code is "using a framework" in one community and "wiring up some libraries" in another.
  • FRAMEWORK-SPECIFICHow much a framework claims varies enormously: some own only the request lifecycle and leave your model alone, while others own persistence, validation, serialization and background work, so that adopting one is adopting a data model. The decision is not "framework or not" but "how many of these does this one claim", and that is answerable before adoption (What a Framework Charges).
  • CONTESTEDThe strongest opposing view: the thin-boundary advice is a tax paid by almost every team to benefit the few who actually migrate, and frameworks are far more often abandoned along with the service than swapped underneath it. On that account, using the framework fully — its ORM entities as your domain model, its validation as your rules — is faster to build, easier to hire for, and the migration that was being insured against never happens. That is a fair reading of how most services end their lives; it is weakest where the code outlives the framework's major version, which in a long-lived service is a certainty rather than a risk.

Where the depth lives

This domain teaches the codebase-level structure and hands the rest off.

Domains that do not exist yet
  • Programming Languages & Runtime Internals — how heavy an inversion feels depends on the language: annotations and reflection-driven wiring make the framework's control flow genuinely invisible, whereas explicit registration keeps it in code you can read.