Domain Logic Organization
Purpose
Decide where business logic goes, on evidence about the logic itself. This is the highest
consequence decision in an enterprise application: it determines what a change costs for
the rest of the system's life, and it is routinely made by habit — a domain model because
the team read about aggregates, or a service class because the previous project had one.
The two failures are symmetrical and equally common. A rich domain model over five CRUD
screens buys mapping code, aggregate loads and a learning curve to protect invariants that
do not exist. A procedural service layer over genuinely interacting rules produces the same
rule written four times, each slightly different, discovered when they disagree in
production.
The three organisations
Transaction Script one procedure per business transaction; data is
structures; logic is steps. Cost grows with rule
interaction, not with rule count.
Domain Model objects with data and behaviour, mirroring the
business; invariants enforced by the objects that
own them. Costs a mapping layer and a load path.
Table Module one class per table (or per record type) holding
the logic for all rows of that table; operates
over a record set rather than per-instance.
Set-oriented, close to the data, no identity map.
The distinction that matters is not "objects versus procedures" but where an invariant is
enforced: in every procedure that touches the data, in the object that owns the data, or
in the table class that owns the set.
Workflow
- Inventory the rules, not the entities. List the actual business rules, then mark
which ones depend on other rules or on state the operation must first establish. Rule
interaction, not rule count, is the deciding evidence.
- Check for shared state across operations. If six operations must each maintain the
same invariant, that invariant wants an owner (Domain Model). If each operation stands
alone, procedures are cheaper and clearer.
- Check the shape of the work. Per-instance decisions favour a Domain Model.
Set-shaped work — recalculate every line in a batch, apply a rate change to a million
rows — favours Table Module or plain SQL. Object hydration can add major allocation and
round-trip cost; measure the workload rather than assuming a ratio
(
architecture-and-performance).
- Check the volatility. Rules that change monthly reward the organisation that makes a
change local. Rules that have not changed in five years reward the one with least
ceremony.
- Decide per module, not per system. A pricing engine and an admin CRUD screen may
benefit from different organisations. Require evidence for uniformity rather than
forcing either sameness or difference.
- Write down the criterion that would flip the decision — for example, independent
pricing operations repeatedly diverge on one stateful invariant. Rule count alone is
not that criterion. Record the selected owner, rejected alternative and validation case.
Decision rules
Data in, validate, write out; rules do not interact; a few branches
→ Transaction Script. The domain model here is pure cost.
Rules interact and coordinating their state across operations is costly
→ Consider Domain Model with explicit invariant ownership; compare
a shared policy/function when stateful objects add no benefit.
An invariant must hold across several operations that update the same
data
→ Give the invariant one logical owner; a Domain Model can enforce
object transitions, while constraints and transaction/concurrency
rules must also protect competing writers.
Logic is genuinely per-table and set-shaped; the platform gives strong
record-set tooling; reporting and bulk updates dominate
→ Table Module, or SQL owned by a gateway. Do not load a million
objects to change a rate.
Complex logic on data owned and shaped by someone else (mainframe, vendor
schema, partner feed)
→ Domain Model plus a translation layer, so the foreign shape does
not become the model (legacy-enterprise-modernization).
Mostly CRUD with a handful of validations, screens map to tables
→ Transaction Script or Active Record. Both are honest; the
domain model is not (data-source-patterns).
Cannot tell yet, module is new and small
→ Transaction Script. It is the cheapest to write and the cheapest
to convert once the rules reveal their shape
(architecture-refactoring-paths).
Rules
- Rule interaction, invariant ownership, volatility and set/per-entity work are evidence—not a
universal count threshold. A dependency map or examples of duplicated decisions are stronger
than saying “the domain is complex,” but “a dozen rules” does not mechanically select a model.
- The anaemic domain model is a real cost, not a purity complaint — but only where a
domain model was the right choice. Entities of getters and setters plus a service holding
the rules is a Transaction Script with an expensive mapping layer attached: you pay the
domain model's price and receive its benefits nowhere. Either move the rules into the
objects or stop paying for the objects.
- A Transaction Script is not a lesser architecture. For non-interacting rules it is
clearer, faster, easier to test and easier to delete. Choose it deliberately and say so,
so the next reader knows it was a decision.
- Transaction Scripts often fail through duplicated or inconsistent rules. Even two occurrences
can be material when correctness or change frequency is high; use divergence and change cost,
not an occurrence threshold.
- Domain Models fail in three ways worth watching for: aggregates too large to load, logic
that leaked into services anyway, and read paths forced through the write model.
Load amplification is visible in traces/query logs; leaked business decisions require
source and change-history inspection as well.
- Table Module is dismissed too quickly in Java, where record-set tooling is weaker than
the platforms it was written for — but its idea survives as a gateway or a service that
owns set-based SQL for one table, and that is frequently the right home for bulk work
next to a domain model doing per-instance work.
- Reads and writes may use different organisations when their forces differ. Protect invariants
through the model on the write path; serve reads with projections or SQL
(
query-objects-and-specifications).
- Do not decide from the persistence pattern. Active Record does not compel Transaction
Script and JPA does not compel a domain model; the organisation of logic and the
data-access pattern are separate choices that constrain but do not determine each other.
- A rewrite is rarely the answer to "we chose wrong". These organisations coexist per
module, and the migration paths are incremental
(
architecture-refactoring-paths).
References
For implementation changes, inspect compiler/runtime, Spring/JPA versions, persistence access
strategy and transaction proxy configuration. Examples are partial sketches with fixture types
omitted; the JdbcClient variant needs Spring 6.1+ and Java 17+, while sealed/pattern constructs
have the release requirements stated below. Preserve the target rather than upgrading it.
When rules or workload evidence are missing, document the provisional choice and the smallest
example/measurement that could change it.
- Transaction Script and Table Module
— both patterns worked properly: how to keep scripts from becoming a god service, where
their duplication actually appears, Table Module's modern Java form, and the honest
ceiling of each. Read when the logic is thin or set-shaped, or when a service class has
outgrown its structure.
- Domain Model — what makes a model rich rather than
anaemic, invariants and their enforcement point, the aggregate load cost, the failure
modes (giant aggregate, leaked logic, read path through the write model), and how to tell
a real domain model from an object-shaped script. Read before proposing a domain model,
and when auditing one that is not paying off.
1---2name: domain-logic-organization3description: Choosing where business rules live — Transaction Script, Domain Model or Table Module — from the shape of the logic rather than from convention, and recognising when the choice made no longer fits. Use when starting a new module and the "standard" layered structure is about to be applied by default, when a service class has grown past a thousand lines of procedural steps, when entities have only getters and setters and every rule sits in a service, when the same business rule is implemented in three places, when a domain model is proposed for CRUD screens, when set-based updates are being rewritten as object loops, or when a report needs data that the aggregate boundary makes expensive to reach. Does not cover the application service that wraps whichever choice you make (service-layer-design), the persistence patterns underneath it (data-source-patterns, repository-pattern), transaction boundaries (enterprise-transactions), or the migration between organisations once chosen (architecture-refactoring-paths).4---56# Domain Logic Organization78## Purpose910Decide where business logic goes, on evidence about the logic itself. This is the highest11consequence decision in an enterprise application: it determines what a change costs for12the rest of the system's life, and it is routinely made by habit — a domain model because13the team read about aggregates, or a service class because the previous project had one.1415The two failures are symmetrical and equally common. A rich domain model over five CRUD16screens buys mapping code, aggregate loads and a learning curve to protect invariants that17do not exist. A procedural service layer over genuinely interacting rules produces the same18rule written four times, each slightly different, discovered when they disagree in19production.2021## The three organisations2223```text24Transaction Script one procedure per business transaction; data is25 structures; logic is steps. Cost grows with rule26 interaction, not with rule count.2728Domain Model objects with data and behaviour, mirroring the29 business; invariants enforced by the objects that30 own them. Costs a mapping layer and a load path.3132Table Module one class per table (or per record type) holding33 the logic for all rows of that table; operates34 over a record set rather than per-instance.35 Set-oriented, close to the data, no identity map.36```3738The distinction that matters is not "objects versus procedures" but **where an invariant is39enforced**: in every procedure that touches the data, in the object that owns the data, or40in the table class that owns the set.4142## Workflow43441. **Inventory the rules, not the entities.** List the actual business rules, then mark45 which ones depend on other rules or on state the operation must first establish. Rule46 _interaction_, not rule count, is the deciding evidence.472. **Check for shared state across operations.** If six operations must each maintain the48 same invariant, that invariant wants an owner (Domain Model). If each operation stands49 alone, procedures are cheaper and clearer.503. **Check the shape of the work.** Per-instance decisions favour a Domain Model.51 Set-shaped work — recalculate every line in a batch, apply a rate change to a million52 rows — favours Table Module or plain SQL. Object hydration can add major allocation and53 round-trip cost; measure the workload rather than assuming a ratio54 (`architecture-and-performance`).554. **Check the volatility.** Rules that change monthly reward the organisation that makes a56 change local. Rules that have not changed in five years reward the one with least57 ceremony.585. **Decide per module, not per system.** A pricing engine and an admin CRUD screen may59 benefit from different organisations. Require evidence for uniformity rather than60 forcing either sameness or difference.616. **Write down the criterion that would flip the decision** — for example, independent62 pricing operations repeatedly diverge on one stateful invariant. Rule count alone is63 not that criterion. Record the selected owner, rejected alternative and validation case.6465## Decision rules6667```text68Data in, validate, write out; rules do not interact; a few branches69 → Transaction Script. The domain model here is pure cost.7071Rules interact and coordinating their state across operations is costly72 → Consider Domain Model with explicit invariant ownership; compare73 a shared policy/function when stateful objects add no benefit.7475An invariant must hold across several operations that update the same76data77 → Give the invariant one logical owner; a Domain Model can enforce78 object transitions, while constraints and transaction/concurrency79 rules must also protect competing writers.8081Logic is genuinely per-table and set-shaped; the platform gives strong82record-set tooling; reporting and bulk updates dominate83 → Table Module, or SQL owned by a gateway. Do not load a million84 objects to change a rate.8586Complex logic on data owned and shaped by someone else (mainframe, vendor87schema, partner feed)88 → Domain Model plus a translation layer, so the foreign shape does89 not become the model (legacy-enterprise-modernization).9091Mostly CRUD with a handful of validations, screens map to tables92 → Transaction Script or Active Record. Both are honest; the93 domain model is not (data-source-patterns).9495Cannot tell yet, module is new and small96 → Transaction Script. It is the cheapest to write and the cheapest97 to convert once the rules reveal their shape98 (architecture-refactoring-paths).99```100101## Rules102103- Rule interaction, invariant ownership, volatility and set/per-entity work are evidence—not a104 universal count threshold. A dependency map or examples of duplicated decisions are stronger105 than saying “the domain is complex,” but “a dozen rules” does not mechanically select a model.106- **The anaemic domain model is a real cost, not a purity complaint** — but only where a107 domain model was the right choice. Entities of getters and setters plus a service holding108 the rules is a Transaction Script with an expensive mapping layer attached: you pay the109 domain model's price and receive its benefits nowhere. Either move the rules into the110 objects or stop paying for the objects.111- A Transaction Script is not a lesser architecture. For non-interacting rules it is112 clearer, faster, easier to test and easier to delete. Choose it deliberately and say so,113 so the next reader knows it was a decision.114- Transaction Scripts often fail through duplicated or inconsistent rules. Even two occurrences115 can be material when correctness or change frequency is high; use divergence and change cost,116 not an occurrence threshold.117- Domain Models fail in three ways worth watching for: aggregates too large to load, logic118 that leaked into services anyway, and read paths forced through the write model.119 Load amplification is visible in traces/query logs; leaked business decisions require120 source and change-history inspection as well.121- Table Module is dismissed too quickly in Java, where record-set tooling is weaker than122 the platforms it was written for — but its idea survives as a gateway or a service that123 owns set-based SQL for one table, and that is frequently the right home for bulk work124 next to a domain model doing per-instance work.125- Reads and writes may use different organisations when their forces differ. Protect invariants126 through the model on the write path; serve reads with projections or SQL127 (`query-objects-and-specifications`).128- Do not decide from the persistence pattern. Active Record does not compel Transaction129 Script and JPA does not compel a domain model; the organisation of logic and the130 data-access pattern are separate choices that constrain but do not determine each other.131- A rewrite is rarely the answer to "we chose wrong". These organisations coexist per132 module, and the migration paths are incremental133 (`architecture-refactoring-paths`).134135## References136137For implementation changes, inspect compiler/runtime, Spring/JPA versions, persistence access138strategy and transaction proxy configuration. Examples are partial sketches with fixture types139omitted; the JdbcClient variant needs Spring 6.1+ and Java 17+, while sealed/pattern constructs140have the release requirements stated below. Preserve the target rather than upgrading it.141When rules or workload evidence are missing, document the provisional choice and the smallest142example/measurement that could change it.143144- [Transaction Script and Table Module](references/transaction-script-and-table-module.md)145 — both patterns worked properly: how to keep scripts from becoming a god service, where146 their duplication actually appears, Table Module's modern Java form, and the honest147 ceiling of each. Read when the logic is thin or set-shaped, or when a service class has148 outgrown its structure.149- [Domain Model](references/domain-model.md) — what makes a model rich rather than150 anaemic, invariants and their enforcement point, the aggregate load cost, the failure151 modes (giant aggregate, leaked logic, read path through the write model), and how to tell152 a real domain model from an object-shaped script. Read before proposing a domain model,153 and when auditing one that is not paying off.