The idea: one rule, three places to put it
Every business application has domain logic: the rules of the business, apart from screens and storage. Martin Fowler's Patterns of Enterprise Application Architecture names three main ways to organize it, and runs one small example through all three: revenue recognition, the question of when money from a signed contract may be counted on the books. A company sells three products. A word processor books all its revenue on the day the contract is signed. A spreadsheet books one third on signing, one third at day 60 and one third at day 90. A database books one third on signing, one at day 30 and one at day 60. The page codes these rules three times and runs each contract through all three at once, so you can see where the rule lives, what is touched, which SQL runs and which rows come out.
The database has three tables: products (ID, name, type), contracts (ID, product, revenue, dateSigned) and revenueRecognitions (contract, amount, recognizedOn) with the primary key (contract, recognizedOn). If a contract has recognitions, they must add up to its revenue. Each style writes its own copy of the recognitions table, so the three results can be compared row by row.
Transaction Script
A Transaction Script is one procedure per thing a user can do. calculateRevenueRecognitions(id) reads the contract and its product through a thin Table Data Gateway, tests the product type in an if chain, splits the money and inserts the rows. Most developers can read it at once, and the transaction boundary is obvious: begin at the top, commit at the end. Scripts on one subject usually share a class; Fowler also shows each script as its own command object (see the Command pattern). Demo 1: one contract, three ways shows it reading one joined row and sending one INSERT per recognition straight away.
Table Module
A Table Module is one class per table with one instance for all its rows. The client first fills a record set (a .NET DataSet) with queries, then hands it to Contract, Product and RevenueRecognition modules. Because an instance stands for the whole table, every call takes an ID: contract.CalculateRecognitions(102), product.GetProductType(2). The rows are added to the DataTable in memory and written by an adapter when the record set is saved. It fits platforms whose screens and tools are built around record sets. Logic grouped by table is easier to search for duplicates than a pile of scripts, but there is no object per row, so the type is still picked by a switch.
Domain Model
A Domain Model has one object per row that holds both data and behaviour. Contract.calculateRecognitions() asks its Product, which passes the job to its RecognitionStrategy: a CompleteRecognitionStrategy for word processors, a ThreeWayRecognitionStrategy(60, 90) for spreadsheets and (30, 60) for databases. No conditional runs during the calculation; the choice was made when the product was wired to its strategy. Getting objects in and out of tables needs a Data Mapper and a Unit of Work, which the book leaves out of its example. The page adds a small ProductMapper registry from type code to strategy, marked as its own addition. A simple domain model that mirrors the tables can use Active Record; one with strategies and inheritance needs a Data Mapper. See also Strategy and Factory Method.
| Transaction Script | Table Module | Domain Model | |
|---|---|---|---|
| organized by | user action | table | business concept |
| instances | none needed | one per table | one per row |
| picks the rule by | if chain | switch on an enum | the product's strategy object |
| SQL for one calculation | 1 + n (INSERTs at once) | 3 + n (INSERTs on save) | 2 + n (INSERTs on commit) |
| data source layer | Table or Row Data Gateway | record sets | Active Record or Data Mapper |
When the rule is missing
Sales can sell a product before the code knows its rule. Demo 2: a product with no rule yet calculates contract 105 for Thinking Show, type P. The book's script has no final else, so it falls through and commits nothing, with no error: the 50.00 is never recognized and nobody is told. The table module ends its switch with throw "invalid product id". The domain model fails even earlier, when the mapper finds no strategy for type P. An error that stops the job is better than a silent nothing.
Adding rules: count the places
Make Next Change climbs a ladder of four change requests. For each style the page counts the places to edit, the copies of the type switch and the rules at risk: existing branches inside edited code, which must be tested again. C1 and C2 are the page's own inventions; C3 (one twelfth a month) and C4 (a signing-date cut-off) follow remarks in the book.
| change | Transaction Script | Table Module | Domain Model |
|---|---|---|---|
| C1 new product Thinking Show: thirds at day 0, 45, 90 | + branch in the script: 1 place, 3 at risk | + enum value, + branch: 2 places, 3 at risk | + registry entry, no new class: 1 place, 0 at risk |
| C2 second use case: quote preview | + a script with a copy of the if chain: 1, 0 | switch moved into Product.Schedule: 2, 4 | + previewSchedule reusing the strategies: 1, 0 |
| C3 support plan: 1/12 every 30 days | + branch in both copies: 2, 8 | + enum value, + branch, one module: 2, 4 | + MonthlyRecognitionStrategy: 2, 0 |
| C4 spreadsheets signed from 15 April: day 0, 30, 60 | nested if in both copies: 2, 10 | nested if: 1, 5 | + DatedRecognitionStrategy: 2, 1 |
| total | 6 places, 21 at risk, 2 copies | 7 places, 16 at risk, 1 copy | 6 places, 1 at risk, 0 copies |
The places come out about even. What separates the styles is where the edits land: the script and the table module keep editing code that already works, while the domain model mostly adds new classes beside the old ones. That is the open/closed principle from SOLID, and the copied switch is the kind of duplication Coupling and Cohesion counts. Demo 3: add rules shows the copy appear and the new monthly strategy; Demo 4: climb the whole ladder ends with the totals above.
Effort and complexity
Fowler sketches the trade-off as three curves of effort to enhance against complexity of the domain logic, and says himself that the axes have no units. With simple logic, the Transaction Script is cheapest and the Domain Model dearest: its objects take a while to learn and its mapping to tables is a fixed cost. As rules and use cases pile up, the script and then the table module hit a wall, while the domain model's effort grows slowly. A team that already knows domain models moves that curve down; good record-set tooling makes the table module more attractive. The styles mix: scripts for simple actions, a domain model for the hard part. Moving from scripts to a domain model is worth doing when the scripts crack; going the other way rarely pays.
Splitting money
Dividing 100.00 by 3 gives 33.333…, and rounding each part gives 33.33 three times: 99.99, a cent lost. Dividing 200.00 gives 66.67 three times: 200.01, a cent made up. The book's Money.allocate works in whole cents and gives the leftover cents to the first parts: 33.34 + 33.33 + 33.33 and 66.67 + 66.67 + 66.66. The monthly plan splits 100.00 into four parts of 8.34 and eight of 8.33. Demo 5: losing a penny ticks round each part and shows the sum check fail in all three styles.
Service Layer
Domain logic is not all the logic. After the recognitions are calculated, the application must email the contract administrator and tell other systems through a message queue. Randy Stafford's chapter in the book calls this application logic, and puts it in a Service Layer: RecognitionService.calculateRevenueRecognitions(n) is the one entry point that a web UI, a data loader and an integration gateway all call. It reads the contract, lets the domain model do the domain logic, then sends the email and the message. It is also the transaction boundary: the method is declared transactional, so the saved rows, the email and the message go out together or not at all.
Demo: three clients, one service runs one calculation from each client. Demo: commit fails, nothing leaks calculates contract 102 twice: the second commit breaks the primary key, and the held email and message are thrown away with the rows. Demo: no service layer gives each client its own copy of the coordination. The copies drift: the web UI sends its email before the commit, so a failed commit leaves a wrong notice, and the data loader's copy never got an email step at all. The service can be a thin domain facade that only forwards calls, or an operation script that holds the application logic, as here. Fowler prefers it thin, and adds it only when there is more than one kind of client or more than one transactional resource. Logic used by one use case only can live in a script; logic shared by many belongs in the domain. See also Hexagonal Architecture, where the use cases at the centre play the same role between the driving adapters and the domain.
What the page leaves out
A working Data Mapper and Unit of Work (the page shows only their SQL), typed DataSets, EJB session beans and container-managed distributed transactions, Remote Facades and Data Transfer Objects for remote calls, the controller-entity style, Active Record, real accounting rules, time zones and calendar months (the page adds whole days), and the book's C# allocation helper, which rounds differently from Money.allocate.