DecisionsFRAMEWORK-SPECIFICLIFETIME-SPECIFICCONTESTED

What a Framework Charges

Productivity, conventions and an ecosystem, in exchange for its lifecycle, its architecture, an upgrade obligation and the moments its abstractions leak. All four charges are payable later, which is why only the benefits are visible on day one.

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

We are productive in this framework. What is it charging us, and when does the bill arrive?

The requirement

A four-year-old service built on a full-stack framework needs to move to the framework's next major version, which changed how its ORM handles lazy loading and how middleware composes. Security patches for the current version end in five months.

The obvious build

The framework paid for itself in the first month. Upgrades are a maintenance task and get scheduled like any other.

Why it breaks

The bill is not proportional to the benefit and does not arrive at the same time. The productivity is delivered in week one; the upgrade cost lands four years later, on a different team, against a deadline set by someone else's support policy.

How it breaks as requirements change
  • The bill is not proportional to the benefit and does not arrive at the same time. The productivity is delivered in week one; the upgrade cost lands four years later, on a different team, against a deadline set by someone else's support policy.
  • The upgrade is not a version bump because the framework owns control flow. A change to how middleware composes changes when your code runs, and that is not something your test suite necessarily notices (Library or Framework).
  • Four years of the framework's conventions have become the architecture: entities are the domain model, handlers hold rules, and lifecycle hooks hold effects. All of that is inside the inversion and all of it moves (Package by Layer).
  • And the leaks are where the time actually goes. Lazy loading behaving differently is an abstraction leaking a persistence detail into business code that never mentioned persistence (Leaky Abstractions).
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 service handles payments and cannot have a long freeze.
  • The framework version is used by two other services with different upgrade appetites.
  • Roughly two hundred handler classes, and an unknown number of places that rely on lazy loading working the way it currently does.
Invariants
  • Behaviour is unchanged by the upgrade: an upgrade that alters what customers are charged is not an upgrade (What Refactoring Actually Is).
  • The service stays on a version that receives security patches.

Who owns what, and where the seams fall

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

Responsibilities
  • The framework owns the lifecycle, wiring and conventions — that is what was bought, and using it fully for those is correct.
  • Your code owns the business rules, and owning them means keeping them where a framework upgrade cannot reach them.
  • Someone must own the upgrade cadence as ongoing work rather than as an event, because the cost of skipping versions compounds (Dependency Management).
Boundaries
  • The boundary that determines the upgrade cost is the one between framework-shaped code and everything else. Two hundred handlers that only map and delegate are a large but mechanical change; two hundred handlers containing rules are a rewrite (Boundary Adapters).
  • Framework abstractions leak at predictable places — persistence, transactions, serialization, async — and those are the places worth an explicit seam even when the framework offers to handle them (Effect Boundaries).

The exchange, stated in full

Every row of this is real, in both columns. A framework adoption is not a mistake to be regretted; it is a purchase whose terms are worth reading, and the terms are almost never discussed at the moment of purchase because the benefits column is what the evaluation was about.

The asymmetry is timing. Everything on the left is delivered immediately and everything on the right is payable later — usually to different people, on a schedule you do not set.

What it gives, immediatelyWhat it charges, laterWhat decides how big the charge is
Productivity: routing, wiring, serialization and migrations that you did not writeAn upgrade obligation on someone else's schedule, with a support window you do not controlWhether you upgrade continuously or in four-year events (Dependency Management)
Conventions: five engineers who agree where things go without a meetingThe conventions become the architecture, including the ones that were never right for your domainWhether the domain model is expressed in the framework's types or in your own (Domain Modeling)
Ecosystem: a plugin for everything, and answers to your questions already writtenYour upgrade is gated by the slowest plugin, and the ecosystem thins as the framework agesHow many plugins you depend on and how central each is (Transitive Dependencies)
Abstraction: persistence, transactions and async handled for youThe leaks — lazy loading, transaction scope, cancellation — surface as bugs in code that never mentioned persistenceWhether effects have an explicit boundary or are wherever the framework allows them (Effect Boundaries)
Onboarding: hire for the framework, not for your codebaseNobody can reason about a handler without knowing the framework's lifecycleHow much behaviour sits inside hooks and decorators rather than in plain functions (Local Reasoning)

The bill, four years later

A major version upgrade is where all four charges are collected at once, and the size of the bill was determined years earlier by a decision nobody framed as being about upgrades: whether the business rules live inside the framework's reach.

Both columns describe the same two hundred handler classes. The difference is not how many files change — it is whether changing them requires understanding what each one does.

Framework major version: middleware composition and lazy loading both change
The change

Move a payments service to the next major version before security patches end in five months.

Rules inside handlers and ORM entities; effects in lifecycle hooks
200 handler classesORM entities (also the domain model)Lifecycle hooks holding audit and notification effectsCustom middleware
testsintegration suite that boots the frameworka handful of unit tests that mock the ORM
4 modules · 2 test files

Every file has to be read, because every file contains behaviour. The tests boot the framework, so they change with it and cannot be trusted as a baseline. Lazy loading changing means every implicit fetch is a potential behaviour change in a service that moves money.

Rules in plain functions; handlers map and delegate; effects at an explicit boundary
200 handler classesCustom middlewarePersistence adapters
testsdomain suite (no framework)integration suite
3 modules · 2 test files

The same two hundred files change, mechanically and often by codemod, and the domain suite — which never booted the framework — is the evidence that nothing moved. Lazy loading is still a real problem, but it is contained in the persistence adapters where fetching is explicit.

what it cost The second design paid for this with four years of mapping code: translating between framework entities and domain types, hand-written where the framework offered to do it automatically. That is thousands of lines of genuinely boring code, reviewed and maintained, buying an upgrade that arrives once — and if the service had been retired in year three, all of it would have been waste. What it also bought, every single day, is a handler you can read without knowing the framework's lifecycle (Local Reasoning).

Where the rules go to hide

The specific mechanism by which a framework ends up owning your business rules is worth naming, because it happens one convenient decision at a time and each decision is defensible.

A hook is offered, it is exactly where the data is, and putting the rule there saves threading a parameter. Four years later the rules are distributed across a lifecycle that the next major version is entitled to redefine.

smellBusiness logic in a framework hookRules in the lifecycle

looks like Domain behaviour in beforeSave, afterCommit, onSerialize or a request interceptor: a discount applied in an entity callback, an email sent after a transaction commits, a permission enforced in middleware and nowhere else. The domain code reads as though none of it happens, because from the domain's point of view none of it does.

suggests The rule was placed where the data happened to be available rather than where it belongs. It is now invisible to anyone reading the domain, untestable without the framework, and scheduled for renegotiation at the next major version (Hidden Global State).

fix Return the effect instead of performing it: the domain function produces a decision and an event, and the framework layer carries it out. That single move takes the rule out of the lifecycle, makes it testable without the framework, and takes it off the upgrade surface — at the cost of a parameter or a return value that has to be threaded through (Effect Boundaries).

when this is fine Cross-cutting concerns with no business exceptions genuinely belong here: request logging, tracing, metrics, and a top-level error translator. The distinguishing question is whether the behaviour has cases — if the answer to "does this ever not apply?" is anything other than "no", it is a rule and it is in the wrong place (Simple Is Not Easy).

How to build it

Most important first.

  • Take the conventions and refuse the architecture. Use the routing, the wiring and the project layout; keep the domain model out of the framework's entity types (Domain Modeling).
  • Upgrade continuously rather than in events. A minor version every month is boring; four years of accumulated majors is a project with a deadline set externally.
  • Put business rules where the upgrade cannot reach: plain functions and types, tested without the framework running (Testing as Design Feedback).
  • Treat lifecycle hooks as a place for framework concerns only. A rule in a hook is a rule the next major version gets to renegotiate (Hidden Global State).
  • Before adopting, count what the framework claims: routing, persistence, validation, serialization, jobs, auth. Each claim is a future upgrade surface, and some frameworks claim far more than others (Build, Library, SaaS or Managed Service).

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
  • Rules inside handlers and entities: the upgrade touches two hundred files where each one has to be understood, not just mechanically changed, because each contains behaviour. Estimate is unreliable and the risk is behavioural.
  • Rules outside: the upgrade touches the same two hundred files mechanically, and the domain tests — which never boot the framework — are the safety net proving nothing moved. Estimate is roughly linear and the risk is schedule, not correctness.
  • What is unchanged in both: the lazy-loading leak. Every place that relied on an entity fetching related data on access has to be found and made explicit, and that is a persistence detail that leaked into business code years ago (N+1 as a Design Problem).
What the recommended approach costs
  • Keeping the domain framework-free means mapping layers and hand-written translation that the framework would have done for free, and that code has to be maintained and reviewed.
  • Refusing the framework's ORM entity as the domain model gives up a lot of genuine convenience — lazy relations, change tracking, migrations generated from types — and some teams will trade a hard upgrade for four years of that.
  • Continuous upgrading spends a steady amount of time on work with no feature attached, and that is real cost, not just insurance. Some services are deleted before the bill ever arrives.

What can go wrong

Failure modes
  • The upgrade is deferred repeatedly until the version is unsupported, at which point it is a security problem rather than an engineering one.
  • The team upgrades by rewriting behaviour it did not understand, and the payment service starts charging slightly differently (Characterization Tests).
  • The framework is abandoned upstream, and the ecosystem that was the reason to adopt it evaporates faster than the framework does.
  • Overreaction: the team wraps every framework feature in its own abstraction, pays the framework's cost, gets none of its conventions, and now maintains a second framework (Over-Design and Under-Design).
Dependencies, and their direction
  • You depend on the framework's release cadence, its support window and its maintainers' decisions about breaking changes — none of which you influence (Deprecation).
  • The ecosystem is a dependency multiplier: plugins are pinned to framework majors, so an upgrade is gated by the slowest plugin you use (Transitive Dependencies).
  • Your team depends on the framework's conventions for shared understanding, which is the benefit — and it means a migration is also a retraining cost (Knowledge Sharing).
Misreads
  • "Frameworks are a trap." They are the reason four people can ship a service in a week with conventions they share. The charge is real and so is the value; the mistake is not knowing what was bought (Library or Framework).
  • "Wrap everything and stay portable." Full portability means paying the framework's cost with none of its leverage, and portability is almost never exercised (Speculative Generality).
  • "Our tests will catch upgrade problems." Only if they test behaviour rather than the framework's wiring. A suite of tests that boots the framework and asserts on its mechanics changes with the framework (Mocking).
  • "We can skip to the newest version later." Skipping majors multiplies the change surface; upgrade cost is superlinear in versions skipped (Incremental Migration).
Smells this explains
  • hidden-global-state
  • anemic-domain-model

Testing it, and how it ages

What to test, and at which boundary
  • Domain tests that never boot the framework are the asset that makes an upgrade tractable. Their existence is decided years before the upgrade (What a Unit Is).
  • For the upgrade itself, characterization at the HTTP boundary: same requests, same responses, same database effects, across both versions (Characterization Tests).
  • A test that fails if a framework type appears in a domain signature is cheap to write as a lint rule and prevents the slow drift that makes the next upgrade expensive (What to Automate Out of Review).
How this design ages
  • Every framework has a lifecycle: adoption, dominance, maintenance, decline. Adopting near the start buys a long runway and unstable APIs; adopting late buys stability and a shorter remaining life (Stability and Dependency Direction).
  • The parts of a codebase that survive framework migrations are the parts that never knew about it, which is the strongest practical argument for keeping the domain framework-free.
  • Upgrade cost compounds with skipped versions, so a team's upgrade cadence in year one largely determines what year four 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.

  • FRAMEWORK-SPECIFICHow much is charged depends on how much the framework claims. One that owns only routing and wiring charges a small upgrade surface; one that owns persistence, validation, serialization and background work charges an upgrade that touches the data model. Counting the claims before adoption is the single most predictive thing you can do (Build, Library, SaaS or Managed Service).
  • LIFETIME-SPECIFICFor a service with a two-year life the upgrade charge is never paid and the productivity is pure gain; frameworks are underrated for short-lived and internal software for exactly this reason. The charges here matter for code expected to outlive at least one major version.
  • CONTESTEDThe strongest opposing view: the domain-free-of-framework discipline is a purity tax that has cost the industry more than framework upgrades ever have. Teams that use the framework fully — entities as the model, its validation as the rules — ship faster for years, hire more easily, and when the upgrade comes it is a mechanical grind that a large community has already documented and tooled. Against that, the honest counter is not portability but local reasoning: the cost is paid daily by everyone who cannot tell what a handler does without knowing the framework, not once at upgrade time (Local Reasoning).

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 invisible a framework's control flow is depends on the language's metaprogramming: annotation and reflection-driven frameworks hide the call graph from every tool you own, including your ability to read it.