The previous post, Why ERP Needs a Definition-Driven Framework, ended by promising this one would be about which decisions a framework should make on its users' behalf.
The difference between a framework and a library is usually explained as "you call a library, a framework calls you." That describes the mechanism, not the price. The real price is that once control is inverted, the framework's defaults become your defaults. It decides what data looks like, how a request flows, and where the extension points are. You are free to disagree, and the cost of disagreeing is working around it, which is usually more expensive than not using it at all.
So the question to ask about a framework is not what it can do. The more things it can do, the fewer decisions it has made for you, and the same decision has to be made again for every single form.
This post answers the four questions a definition-driven framework has to settle first. Every other design choice is a consequence of these four answers:
- Which layer holds the single source of truth
- Which layers are derived from it
- What is left to the application
- Where this whole approach does not hold
None of the four can be left blank. If the framework does not answer them, the people using it will answer them individually, and their answers will not agree. This module puts the truth in an entity, that one puts it in the table, a third puts it in a form configuration file on the front end. A good part of the three long-standing problems from the previous post grows out of exactly that.
1. Which layer holds the single source of truth
First, a distinction that is easy to blur: a Code-First stack is not short of definitions. The API contract is a definition, the migration script is a definition, so is the database's own metadata. The difference is not whether definitions exist. It is which way the arrow points. Those definitions are dumped out as products. Editing them is meaningless, because the next generation run overwrites them.
So the question is not whether to have definition files. It is where the arrow should point, and there are only three candidates:
| Candidate | Paradigm | The catch |
|---|---|---|
| Code | Code-First | A type system is wide enough to hold any semantics you can think of. The price is that the only reader of the truth is the compiler, and changing the truth means going through compilation, packaging and deployment |
| The database | Database-First | Data is the final fact, but the type system is too narrow |
| Definitions | Definition-Driven | Has to satisfy two conditions at once |
Where the database is narrow: it knows that total_amount is a decimal(18,4), but not whether that number is an amount, a quantity or an exchange rate. It does not know whether the value should be rounded, how many digits to show, whether it is required, whether it can be edited, or whether it belongs in the query list. Those semantics have nowhere to live, so they end up somewhere else, and the truth has scattered again.
For definitions to be the truth, two conditions, neither optional
- Wide enough to carry semantics beyond structure
- They are data, not code, which was the whole subject of the previous post
In this framework, what a single field can declare falls into six categories:
| Category | What is declared | Can the database hold it? |
|---|---|---|
| Structure | Field name, data type, length, default value | ✅ |
| Behavior | Required, read only, visible | Partly |
| Presentation | Caption, control type, display format, width | ❌ |
| Semantics | What kind of number this is, which field carries the currency, which field carries the unit of measure | ❌ |
| Relationships | The related form, the field mapping, the display field | ❌ |
| Security | Data scope roles, sensitive data classification | ❌ |
Code can hold all six, but only as a stack of attributes, and once they are stacked someone still has to parse them back into data before anything can use them.
The width of that declaration is deliberate. It has to cover the UI, the database and validation in one pass.
A single source of truth is not a claim, it is a dependency direction
Any framework can write "this framework treats X as the single source of truth" in its documentation. To verify it, the dependency direction is enough: the layer holding the truth should depend on nobody, and everybody should depend on it.
The definition layer package has an extremely short reference list. It does not know a database exists, or HTTP, or a UI. In the other direction, data access, business logic, the API contract, the client, and every front end all reference it.
That direction buys two things:
- The definition layer can be processed by tools on its own. A definition editor, a generator, a batch checking script: each needs to reference only this one package, without dragging in the entire database and API stack. The claim in the previous post that a thousand definitions can be swept by one script rests on this.
- AI benefits too. To make it understand a system, feeding it the definitions is more efficient than having it scan the whole code base, and the conclusions are more reliable. The difference is not file size. It is whether relationships are declared or inferred. Ask which forms an order touches, and reading the definitions means reading relationships that were already declared; reading the code means chasing types, chasing queries, chasing configuration, and then inferring intent, and inference gets things wrong.
The tokens saved are a side effect. As for the business judgments that definitions do not cover, the code still has to be read.
2. Which layers are derived from definitions
The previous post used plain language: the UI, the database, CRUD SQL, validation rules. The precise list comes in two kinds. Most of it is generation:
| What is generated | From |
|---|---|
| The single record form layout | The form structure (master block plus detail block) |
| The list layout | The list fields the form structure declares |
| The lookup window layout | The queryable fields the form structure declares |
| The physical table structure | Every table in the form structure |
| INSERT / UPDATE / DELETE | The form structure's field set |
| SELECT, including cross table joins | The form structure's fields and declared relationships |
| The structure of the data container that crosses layers | The form structure's field set |
The other kind is evaluation. Here the framework does not build anything; it takes the rules and expressions declared in the definition and evaluates them. Validation rules are evaluated at save and delete. Computed fields are recalculated the moment the user changes a field they depend on.
When a requirement converges into a new shared behavior, a matching mechanism grows. The query criteria screen for a report, for example, is essentially a set of fields plus editors, and a layout definition can describe it.
Grouped by who consumes it:
Form structure definition
│
├─ Generates
│ ├─ Three layouts: single record / list / lookup window -> screens on every front end
│ ├─ Physical table structure -> the database
│ ├─ Two kinds of SQL: write / query, including cross table joins -> data access
│ └─ The cross layer data container structure -> transport
│
└─ Evaluates
├─ Validation rules -> at save and delete
└─ Computed fields -> the moment a dependent field changes
Generating code, or generating objects
Growing something out of a definition at build time and at run time produces completely different results:
- A build time generator produces code. When the definition changes, you regenerate, recompile and redeploy. Not one step of the delivery path from the previous post has been removed, and everything saved earlier is handed back at the final gate.
- Run time derivation produces objects in memory. Swap the definition, and the next read is the new one. This is exactly where "no compilation, packaging or deployment between changing a definition and having it take effect" holds.
Two things are paid for it:
- Compile time type checking is gone. A misspelled field name is not discovered until run time, and how that debt gets repaid is the subject of Data and Definitions Split Apart, So Forms Need No Types of Their Own.
- Cache invalidation goes from an optimization to a problem that must be solved. Definitions will be cached for performance, so the framework needs a notification mechanism that carries a definition changed on one node out to every other node.
3. What is left to the application
Where the framework should stop has two rules, one positive and one negative.
The positive one: any behavior that is standardized and repetitive should converge into a framework mechanism.
This is not merely about writing fewer lines:
- There is one way to do each thing, and a fix goes in one place, which saves maintenance
- New joiners, consultants and whoever inherits the system learn it once, and understanding one form means understanding all of them, which saves training and is the most commonly underestimated of the two
The symptoms of losing control listed in the previous post are all invoices issued by the absence of a general mechanism, and they are reissued per person, per year.
The negative rule is a single test: would every ERP in the world do this the same way? What is the same belongs to the framework. What differs belongs to the application.
By that line the framework stays out of three areas:
- Business judgment: how a status transitions, when shipping must be blocked. Every company differs.
- SQL for reports and batch jobs: multi table joins, aggregation, dynamic criteria, performance tuning. Forcing these into definitions only makes them more complicated.
- The application shell: the login screen, connection settings, navigation menu, windows and theme.
Staying out is not the same as ignoring. Having handed those three over, the framework picks up two obligations it must get right:
- A registry that maps a form identifier to the class you wrote
- A few extension points at unambiguous positions, so your logic can hook into the save and delete flows
How well those two are done decides directly whether the application is pleasant or awkward to write, and a later chapter takes them apart.
Handing work over does not reduce complexity either. What definitions can absorb is repetition, not the inherent difficulty of the business. The hard parts have not disappeared. They have been concentrated in one place and given to whoever knows why they are hard.
The third area is clearest in the case study. The Northwind front end has one UI project shared by every front end, and only four hand written screens: the main window, login, connection settings, and the layer that hosts forms. All four are shell. Not one of the eight business forms needs screen code.
You can see where the line is drawn at a glance: the hand written part is precisely the part definitions did not take over.
4. Architectural patterns: three sources, one piece from each
With the first three questions answered, the shape of the framework is fixed. Before the fourth, one description that gets applied to it deserves handling: a hybrid of N-Tier, Clean Architecture and MVVM. "Hybrid" turns into a decorative word very easily, so here is what was taken and what was not.
| Source | What was taken | What was not |
|---|---|---|
| N-Tier | The layer boundaries and the cross layer data container. One request travels front end, API, business object, repository, data access infrastructure, database, and each layer knows only its neighbor. The most completely taken of the three | Nothing |
| Clean Architecture | The inward dependency direction from the first section, verifiable by opening the project reference lists | Its requirement on the domain. The pure approach gives every business concept its own strongly typed domain entity, and modeling a thousand tables one at a time is the scale cost from the previous post itself |
| MVVM | All three roles are present. The model is the cross layer data container, the view is rendered from the layout definition, and the view model is a framework provided DataObject
|
Nothing; the only difference is who writes the view model |
That DataObject carries the full job: wrapping the data container, exposing field reads and writes for screen binding, raising notifications for value changes and unsaved state, managing the start and cancellation of detail row editing, and loading, saving and deleting against the back end. The difference is that it is not hand written per form. Its shape comes from the definition. The price is that the screen binds not to a property like order.CustomerId but to a field name.
The cross layer data container is not a custom type either, and a later post is devoted to what that choice costs.
5. Systems this should not be applied to
Three questions before you commit. If any one of them does not survive, seriously consider not applying this.
Q1: Are your screens standardized forms?
Definitions can drive layout only because the layout patterns converged, and what converged here is a block of master fields plus a detail block. Dashboards, charts, drag and drop scheduling and maps are not in the pattern. The framework's answer is not "we support those too." It is that this road does not go through definitions, and you write it yourself.
Q2: Is your request a single document?
The cross layer data container is self describing, and the price is that every response carries the field structure with it. For one order that is worth it. For an event stream at tens of thousands per second it is not.
Q3: How long does your system have to live?
The cost is concentrated up front and the return does not show for three to five years. A system that ships, closes and gets rewritten wholesale three years later pays the cost and never collects.
Not many scenarios satisfy all three at once, and internal enterprise management systems happen to hit every one. Finance, purchasing, inventory, sales, HR, customer relations: the screens are almost all standardized forms, the requests are almost all documents, and once it goes live it lives a long time.
These boundaries are not "the framework has not got there yet." They are places where getting there would make things worse. A framework that can do anything has made no decisions for you, and the decisions are exactly where a framework's value is.
The Northwind case study: how much one definition does
The order in the case study has a single form structure definition, and it states:
- One master with one detail
- Three cross table relationships on the master, to customer, employee and shipper, each a lookup window that brings one record back
- Each detail row picks a product
- Amounts are not typed by anyone. They are calculated from quantity, unit price and discount
- Three validation rules have to pass before saving
Once that one definition is written, everything in the table from the second section is in place. The tables are created from it, the list and lookup window layouts grow directly out of it, the write and query SQL is assembled by the framework, the data carries its structure with it across layers, and the validation rules are checked at the moment of saving. Not one of those requires additional code.
The same definition is read by four front ends: desktop, browser, iOS and Android, all sharing one UI project.
The value of a framework is not how much it can do. It is whether it can tell which decisions belong to it: the thing that is identical a thousand times over is its, and the thing that differs every time is yours. Draw that line crooked and both sides carry the weight. Draw it too conservatively and you redo the same work a thousand times. Draw it too greedily and you spend more effort working around it.
The next post, The Northwind Case Study: Eight Forms and a Custom Business Object, brings the case study on stage.
📚 Part of the thirty-post series ERP Architect's Notes: Designing a Definition-Driven Framework
📘 Original article on HackMD, in Traditional Chinese:
👉 https://hackmd.io/@jeff377/day-02-framework-decisions
📢 You are welcome to republish this, please credit the source.
📬 Follow for more engineering notes:
Facebook | HackMD | GitHub | NuGet
Top comments (0)