MODEL EXPRESSIONS
An Overlooked Aspect of Domain-Driven Design?
Julien Topçu

FOR YOUr EYES ONLY
◆ Tactical Variance Authority ◆
Problem statement

THE CRIME SCENE
Variants HUNT
sealed class Criteria { //ValueObject
data class OneWay(
val outbound: Journey,
val directTrainOnly: Boolean,
) : Criteria()
data class RoundTrip(
val outbound: Journey,
val inbound: Journey,
val directTrainOnly: Boolean,
) : Criteria()
}
class Search( // Aggregate
val id: UUID,
val criteria: Criteria,
val trains: List<Train>,
val selection: Selection
)
data class Journey( //ValueObject
val departureStationId: String,
val arrivalStationId: String,
val departureSchedule: OffsetDateTime
)
THE CODE
Sacred Model



THE line up
Variants HUNT
suspects
THE LIE DETECTOR
WEAK SPOT
Search
Aggregate
Criteria
Value Object
1
1

THE Inliner
suspect#1
CREATE TABLE search (
id CHAR(36) PRIMARY KEY,
trip_type VARCHAR(16) NOT NULL,
direct_train_only BOOLEAN NOT NULL,
selection_id CHAR(36) REFERENCES selection(id),
-- ...
);
Schema
| id | trip_type | direct_train_only | selection_id | ... |
|---|---|---|---|---|
| search#1 | ROUND_TRIP | true | selection#1 |
Search
Criteria
Table
Variant
Criteria is now an Entity with its own identity!
Schema
CREATE TABLE search (
id CHAR(36) PRIMARY KEY,
criteria_id CHAR(36) NOT NULL UNIQUE REFERENCES criteria(id),
selection_id CHAR(36) REFERENCES selection(id),
-- ...
);
CREATE TABLE criteria (
id CHAR(36) PRIMARY KEY,
trip_type VARCHAR(16) NOT NULL,
direct_train_only BOOLEAN NOT NULL,
-- ...
);
THE FORGER
suspect#2

THE FORGER
suspect#2
| id | selection_id | criteria_id | ... |
|---|---|---|---|
| search#1 | selection#1 | criteria#1 | ... |
Search
Table
| id | trip_type | direct_train_only | ... |
|---|---|---|---|
| criteria#1 | ROUND_TRIP | true | ... |
CRITERIA
Table

THE FORGER
suspect#2
Variant
| id | selection_id | criteria_id | ... |
|---|---|---|---|
| search#1 | selection#1 | criteria#1 | ... |
Search
Table
| id | trip_type | direct_train_only | ... |
|---|---|---|---|
| criteria#1 | ROUND_TRIP | true | ... |
| criteria#2 | ONE_WAY | false | ... |
CRITERIA
Table
Criteria is still an Entity!
The Search doesn't know it contains Criteria!

THE IMpersonator
suspect#3
Schema
CREATE TABLE search (
id CHAR(36) PRIMARY KEY,
selection_id CHAR(36) NULL,
-- ...
);
CREATE TABLE criteria (
search_id CHAR(36) PRIMARY KEY REFERENCES search(id),
trip_type VARCHAR(16) NOT NULL,
direct_train_only BOOLEAN NOT NULL,
CHECK (trip_type IN ('ONE_WAY', 'ROUND_TRIP'))
);
THE IMpersonator
suspect#3
| id | selection_id | ... |
|---|---|---|
| search#1 | selection#1 | ... |
| search#2 | selection#2 |
Search
Table
| id | trip_type | direct_train_only | ... |
|---|---|---|---|
| search#1 | ROUND_TRIP | true | ... |
CRITERIA
Table
Variant
What ties the number of journeys to the type of Criteria?
THE IMpersonator
suspect#3
CREATE TABLE criteria (
search_id CHAR(36) PRIMARY KEY REFERENCES search(id),
trip_type VARCHAR(16) NOT NULL,
direct_train_only BOOLEAN NOT NULL,
CHECK (trip_type IN ('ONE_WAY', 'ROUND_TRIP'))
-- ONE_WAY = 1 Journey, ROUND_TRIP = 2 Journeys?
);
CREATE TABLE journey (
criteria_id CHAR(36) NOT NULL REFERENCES criteria(search_id),
bound VARCHAR(8) NOT NULL,
departure_station_id CHAR(36) NOT NULL,
departure_schedule CHAR(25) NOT NULL,
arrival_station_id CHAR(36) NOT NULL,
PRIMARY KEY (criteria_id, bound),
CHECK (bound IN ('OUTBOUND', 'INBOUND'))
);SCHEMA
Plain SQL cannot enforce this. CHECK is row-local.
THE IMpersonator
suspect#3
ALTER TABLE criteria
ADD CONSTRAINT journey_count_matches_trip_type
CHECK (
(trip_type = 'ONE_WAY'
AND (SELECT COUNT(*) FROM journey WHERE criteria_id = search_id) = 1)
OR (trip_type = 'ROUND_TRIP'
AND (SELECT COUNT(*) FROM journey WHERE criteria_id = search_id) = 2)
);SCHEMA
Illicit
in SQL
OneWay has one journey. RoundTrip has two.
Invariant
THE IMpersonator
suspect#3
| id | bound | departure_station_id | arrival_station_id | ... |
|---|---|---|---|---|
Journey
Table
| id | trip_type | direct_train_only | ... |
|---|---|---|---|
Criteria
Table
a ROUND_TRIP with one journey? accepted!
THE IMpersonator
suspect#3
| id | bound | departure_station_id | arrival_station_id | ... |
|---|---|---|---|---|
| search#1 | OUTBOUND | PAR | LON | ... |
Journey
Table
| id | trip_type | direct_train_only | ... |
|---|---|---|---|
| search#1 | ROUND_TRIP | true | ... |
Criteria
Table
a ROUND_TRIP with one journey? accepted!
THE IMpersonator
suspect#3
a ONE_WAY with an INBOUND? accepted!
| id | bound | departure_station_id | arrival_station_id | ... |
|---|---|---|---|---|
| search#1 | OUTBOUND | PAR | LON | ... |
| search#2 | INBOUND | BRU | AMS |
Journey
Table
| id | trip_type | direct_train_only | ... |
|---|---|---|---|
| search#1 | ROUND_TRIP | true | ... |
| search#2 | ONE_WAY | true |
Criteria
Table

THE IMpersonator
suspect#3
Variant
SQL is broken!
Tribunal order: Prune all of it!
verdict
Code : Round-trip has exactly 2 journeys
SACRED MODEL
SQL : Criteria has journeys, never how many
VARIANCE
THE LIE DETECTOR
WEAK SPOT
Search
change(criteria): Search
select(...): SearchWithIncompleteSelection
SearchWithIncompleteSelection
// no change(criteria), frozen after the first select
select(...): SearchWithCompleteSelection
unselect(...): Search
SearchWithCompleteSelection
// no change(criteria)
unselect(...): SearchWithIncompleteSelection
book(): BookingSACRED MODEL
The rule never reaches the wire!
Variant

THE CRUD-Slinger
suspect#4
EVIDENCE #1: no selection yet
PATCH /searches/abc/criteria HTTP/1.1
Content-Type: application/json
{ "directTrainOnly": true }HTTP/1.1 200 OKRequest
response
PATCH /searches/abc/criteria HTTP/1.1
Content-Type: application/json
{ "directTrainOnly": false }EVIDENCE #2: SELECTED OUTBOUND
HTTP/1.1 422 Unprocessable Entity
Content-Type: application/json
{ "error": "Criteria can no longer be changed" }Request
response
◆ TVA Tribunal · Pruning Orders ◆
each describe a different Search within the same bounded-context
Every medium has its own grammar
Verdict
EXHIBIT A
CODE
BUILT FOR
BUSINESS RULES
EXHIBIT B
SQL
BUILT FOR
STORAGE
TO PRUNE
EXHIBIT C
REST
BUILT FOR
EXPOSURE
TO PRUNE
What is a model expression?

Ancillary
Ship

Select
Ancillary

Ancillary
Selected


Search
Bound


Fare
Selected On Bound

Check
Selection
Completion

Check
Selection
Completion

Selection
COmpleted

Selection

Selection Found Incomplete

Ancillaries
Retrieval

List
Ancillaries

Ship
Fare

Ancillaries
Listed

Ancillaries

Select
a Fare

Incomplete
Selection

complete
Selection


Price
Selection

Total Price Computation

Selection
Priced

Priced
Selection
Selection
Talking is modelling
We express the model in each layer of the system, each expression addressing a specific concern
That is a Model Expression
THE MODEL IS INTANGIBLE
statement
An expression preserves the dimensions that match its grammar's concern, and loses the rest
01
Each expression is
GRAMMAR-BOUNDED
02
Each grammar is
concern-focused
MODEL EXPRESSION
Constraints
EXHIBIT B
SQL
LOSES
BEHAVIOR
preserves
STRUCTURE
LOSES
WORKFLOW
preserves
RESOURCE STATE
EXHIBIT C
REST
EXHIBIT D
USER STORY
LOSES
STRUCTURE
preserves
INTENT
-
Business logic → Code
-
Persistence → SQL, MongoDB...
-
System Affordance → REST, GraphQL, Messages...
-
Presentation → UI, mobile...
-
Observability → Dashboards, bug tracker, metrics...
-
Structure / Modularity → Architecture
technical
one model, many Dimensions
-
SPECIFICATION → user stories, scenarios...
-
documentation
-
workflow → business processes
-
shared understanding → EventStorming
-
communication → talking is modelling
social
one model, many Dimensions
THE ORGANIZATION IS A MODEL EXPRESSION
The organization is a Solution to the problem, on the human dimension
User
Shopping
Payment
Billing
Social dimension
We only deal with expressions of an intangible model
through different dimensions of the socio-technical system
BUSINESS
Model Expression
org
Model Expression
Model Expression
architecture
process
Model Expression
The relationship between model expressions and a bounded-context
What is the link between a model expression and a bounded-context?
exploring The solution space

Speak a ubiquitous language within an explicit bounded-context.
— Eric Evans
What makes a bounded-context?
→
01
BOUNDED-CONTEXT
01
ubiquitous language
01
model
→
database
Model Expression
Model Expression
user Story
Domain code
Model Expression
Model Expression
UI
Bounded-context
Intangible Model
Before
As a traveller,
I want to search for a one-way or a round-trip,
so that I can find trains for my trip.
- The traveller specifies a journey.
- For a round trip, the traveller also specifies a return journey.
the User Story
Model Expressions relationship
After
As a traveller,
I want to search for a multi-destination trip,
so that I can plan it in one search.
- The traveller specifies a first journey.
- The traveller adds further journeys, each continuing from the previous arrival, after it.
Before
THE User interface
Model Expressions relationship
THE User interface
Model Expressions relationship
After
THE CODE
Model Expressions relationship
After
sealed class Criteria {
data class OneWay(val outbound: Journey) : Criteria()
data class RoundTrip(val outbound: Journey, val inbound: Journey) : Criteria()
data class MultiDestination(val journeys: List<Journey>) : Criteria()
}Before
sealed class Criteria {
data class OneWay(val outbound: Journey) : Criteria()
data class RoundTrip(val outbound: Journey, val inbound: Journey) : Criteria()
}Before
CREATE TABLE journey (
...
bound VARCHAR(8) NOT NULL,
PRIMARY KEY (criteria_id, bound),
CHECK (bound IN ('OUTBOUND', 'INBOUND'))
);THE database
Model Expressions relationship
After
CREATE TABLE journey (
...
position INT NOT NULL,
PRIMARY KEY (criteria_id, position)
);Changing a business structuring constraint impact model expressions the same manner
Domain code
Model Expression
Outbound / Inbound
List<Journey>
database
Model Expression
Keyed by bound
Keyed by position
Model Expression
UI
2 fixed fields
Add-a-destination control
Model Expression
2 fixed journeys
List of journeys
user Story
Result

Organizations which design systems are constrained to produce designs which are copies of the communication structures of these organizations
THE conway's Law
HOMOMORPHISM
— Melvin Conway
User
Billing
Payment
Shopping
User
Billing
Shopping
Payment
Each model expression will preserve the structure of the constraints and the relationships between the domain concepts to remain valid
THE HOMOMORPHISM
Model Expressions relationship
database
Model Expression
Model Expression
user Story
Domain code
Model Expression
Model Expression
UI
Bounded-context
Intangible Model
The validity of this system of model expressions depends on their extrinsic cohesion
Do these journeys need to link up?
database
Model Expression
Domain code
Model Expression
Model Expression
UI
Round Trip
has 2 journeys ?
Round Trip
has 2 adjacent journeys!
BOUNDED-CONTEXT
The system of cohesive model expressions is a bounded-context
Domain code
Model Expression
Fare Computation:
Map Reduce
Algorithm
database
Model Expression
DELEgation
Model Expression
Domain code
Model Expression
Fare Computation:
Map Reduce
Algorithm
database
Model Expression
DELEgation
Model Expression
REPLICATION
Model Expression
OFFLINE PWA
Model Expression
Business
Logic
Backend
Model Expression
Business
Logic
OFFLINE PWA
Model Expression
Business
Logic
Backend
Model Expression
Business
Logic
REPLICATION
Model Expression
Model Expressions challenges
Controller
Service
Persistence
class Search(
val id: UUID,
val criteria: Criteria,
// selection, trains, ...
)data class Criteria(
val tripType: TripType,
val directTrainOnly: Boolean,
// journeys...
)Service
Persistence
Controller
Persistence
CREATE TABLE search (
id CHAR(36) PRIMARY KEY,
criteria_id CHAR(36) NOT NULL UNIQUE REFERENCES criteria(id),
selection_id CHAR(36) REFERENCES selection(id),
-- ...
);
CREATE TABLE criteria (
id CHAR(36) PRIMARY KEY,
trip_type VARCHAR(16) NOT NULL,
direct_train_only BOOLEAN NOT NULL,
CHECK (trip_type IN ('ONE_WAY', 'ROUND_TRIP'))
);Surrogate Key Forger
class Search(
val id: UUID,
val criteria: Criteria,
)data class Criteria(
val tripType: TripType,
val directTrainOnly: Boolean,
)Service
Persistence
Tables
ORM
@Entity
@Entity
@OneToOne
@JoinColumn
@Table
@Table
@Id
@Id
ORM
@Table(name = "criteria")
data class Criteria(
val tripType: TripType,
val directTrainOnly: Boolean,
)Persistence
Controller
Tables
ORM
@Entity
@Table(name = "search")
data class Search(
@Id
val id: UUID,
@OneToOne
@JoinColumn(name = "criteria_id")
val criteria: Criteria,
)@Id
@OneToOne
@JoinColumn
@Table
@Entity
@Table
@Id
@Entity
Service
+ ORM
@Entity
@Table(name = "search")
data class Search(
@Id
val id: UUID,
@OneToOne
@JoinColumn(name = "criteria_id")
val criteria: Criteria,
)@Entity
@Table(name = "criteria")
data class Criteria(
@Id
val id: UUID,
val tripType: TripType,
val directTrainOnly: Boolean,
)Persistence
Controller
Tables
Service
+ ORM
@Entity
@Id
@OneToOne
@JoinColumn
@Table
@Entity
@Table
@Id
ORM
Variant
@Entity
@Table(name = "search")
data class Search(
@Id
val id: UUID,
@OneToOne
@JoinColumn(name = "criteria_id")
val criteria: Criteria,
)@Entity
@Table(name = "criteria")
data class Criteria(
@Id
val id: UUID,
val tripType: TripType,
val directTrainOnly: Boolean,
)Controller
Service
+ ORM
Serializer
@JsonProperty
@JsonProperty
@JsonProperty
@JsonProperty
Serializer
Variant
GET /searches/{id}/criteria/{criteriaId}@Entity
@Table(name = "criteria")
data class Criteria(
@Id
@JsonProperty("criteria_id")
val id: UUID,
@JsonProperty("tripType")
val tripType: TripType,
@JsonProperty("directTrainOnly")
val directTrainOnly: Boolean,
)@JsonProperty
Controller
@JsonProperty
@JsonProperty
@JsonProperty
Service
+ ORM
Serializer +
@Entity
@Table(name = "search")
data class Search(
@Id
@JsonProperty("id")
val id: UUID,
@OneToOne
@JoinColumn(name = "criteria_id")
val criteria: Criteria,
)Criteria become an entity for the consumers!
Variant
CROSS-CONTAMINATION
Each grammar brings constraints of its own, tied to its dimension
challenges
A constraint from one expression may leak into a neighbor
Once it spreads to enough expressions, it becomes a structuring constraint
Rail Shopping
Rail Supply
UI

If

If

If

If

If

If

If

If

If
UI
Rail Shopping
Rail Supply



Feature Team
#1



Feature Team
#2



Feature Team
#3
UI
Rail Shopping
Rail Supply
PERMEABILITY
Solve a concern in the wrong expression, and cohesion erodes
challenges
The bounded context stops being always valid

THE SELECTION
Permeability
database
Model Expression
Backend
Model Expression
Model Expression
UI
Inventory
SElection
Inventory
SElection
TGV 9612
72€
TGV 9612
72€
selection
TGV 9612
72€
TGV 9612
72€
TGV 9612
72€
database
Model Expression
Backend
Model Expression
Model Expression
UI
Inventory
SElection
Inventory
SElection
TGV 9612
72€
TGV 9612
72€
selection
TGV 9612
72€
TGV 9612
72€
PUT
TGV 9612
72€
database
Model Expression
Backend
Model Expression
Model Expression
UI
Inventory
SElection
Inventory
SElection
TGV 9612
72€
TGV 9612
72€
selection
TGV 9612
72€
TGV 9612
72€
PUT
TGV 9612
72€
PRICE CHECK
PRICE CHECK
database
Model Expression
Backend
Model Expression
Model Expression
UI
Inventory
SElection
Inventory
SElection
TGV 9612
96€
TGV 9612
72€
selection
TGV 9612
72€
PRICE CHECK
TGV 9612
72€
TGV 9612
72€
database
Model Expression
Backend
Model Expression
Model Expression
UI
Inventory
SElection
Inventory
SElection
TGV 9612
96€
TGV 9612
72€
selection
TGV 9612
72€
PRICE CHECK
TGV 9612
72€
PUT
TGV 9612
72€
database
Model Expression
Backend
Model Expression
Model Expression
UI
Inventory
SElection
Inventory
SElection
TGV 9612
96€
TGV 9612
72€
selection
TGV 9612
72€
PRICE CHECK
TGV 9612
72€
PUT
It was solved in the wrong expression!
Name
Name
Name
Name
Date of Birth
Date of Birth
Date of Birth
Date of Birth
Date of Trip
Date of Trip
Date of Trip
Date of Trip
# Loyalty Card
# Loyalty Card
# Loyalty Card
# Loyalty Card
Payment Method
Payment Method
Payment Method
Payment Method
🏨
Hotel
Dossier
✈️
Flight
Dossier
🚗
Car
Dossier
🚆
Train
Dossier
Name
Name
Name
Name
Date of Birth
Date of Birth
Date of Birth
Date of Birth
Date of Trip
Date of Trip
Date of Trip
Date of Trip
# Loyalty Card
# Loyalty Card
# Loyalty Card
# Loyalty Card
Payment Method
Payment Method
Payment Method
Payment Method
🏨
Hotel
Dossier
✈️
Flight
Dossier
🚗
Car
Dossier
🚆
Train
Dossier
🔥METADOSSIER🔥
METADOSSIER EXCEPTION
🏨
Hotel
Dossier
✈️
Flight
Dossier
🚗
Car
Dossier
🚆
Train
Dossier

METADOSSIER?
🔥METADOSSIER🔥
◆ straight from the pain of the users ◆
Essential business concept
IMPEDANCE MISMATCH
When some model expressions do not preserve the homomorphism
challenges
heavy translation layers compensate for the lack of cohesion
Misalignments are not worked around,
and a business capability discrepancy occurs
"It's developers' misunderstanding, not domain experts' knowledge, that gets released in production."
— Alberto brandolini
The developer's mistake ships as the business experts' expertise
corollary
Feedback Loop
CONFUSING BOUNDED-CONTEXT
AND MODEL EXPRESSIONS
This leads to model tensions, eroding the conceptual integrity of the bounded-contexts
challenges
Sometimes bounded-contexts are considered to be model expressions
sometimes a model expression is overthought as a bounded-context
frontend
backend
"journeys": [ ... ],
"trains": [
{
"number": "A",
"bound" : "OUTBOUND"
},
{
"number": "B",
"bound" : "OUTBOUND"
}
],
"selection": {
"OUTBOUND": { "trainNumber": "A" }
}"legs": [ ... ],
"trains": [
{
"number": "A",
"bound": "OUTBOUND",
"selected": true
},
{
"number": "B",
"bound": "OUTBOUND",
"selected": false
},
]2 models = 2 solutions to 1 problem
A MODEL IS A SOLUTION
confusing bounded-context & expression
frontend = bounded-context
Backend = bounded-context
frontend
backend
"journeys": [ ... ],
"trains": [
{
"number": "A",
"bound" : "OUTBOUND"
},
{
"number": "B",
"bound" : "OUTBOUND"
}
],
"selection": {
"OUTBOUND": { "trainNumber": "A" }
}"legs": [ ... ],
"trains": [
{
"number": "A",
"bound": "OUTBOUND",
"selected": true
},
{
"number": "B",
"bound": "OUTBOUND",
"selected": true
},
]Invariant:
only 1 outbound selected
structurally safe!
BOUNDED-CONTEXT
BOUNDED-CONTEXT
frontend
"legs": [ ... ],
"trains": [
{
"number": "A",
"bound": "OUTBOUND",
"selected": true
},
{
"number": "B",
"bound": "OUTBOUND",
"selected": false
},
]backend
"journeys": [ ... ],
"trains": [
{
"number": "A",
"bound" : "OUTBOUND"
},
{
"number": "B",
"bound" : "OUTBOUND"
}
],
"selection": {
"OUTBOUND": { "trainNumber": "A" }
}Compensating translation layer
BOUNDED-CONTEXT
BOUNDED-CONTEXT
frontend
backend
"journeys": [ ... ],
"trains": [
{
"number": "A",
"bound" : "OUTBOUND"
},
{
"number": "B",
"bound" : "OUTBOUND"
}
],
"selection": {
"OUTBOUND": { "trainNumber": "A" }
}"journeys": [ ... ],
"trains": [
{
"number": "A",
"bound" : "OUTBOUND"
},
{
"number": "B",
"bound" : "OUTBOUND"
}
],
"selection": {
"OUTBOUND": { "trainNumber": "A" }
}Model Expression
Model Expression
BOUNDED-CONTEXT
BOUNDED-CONTEXT
Preserving the conceptual integrity of model expressions
MODEL EXPRESSION,
OR BOUNDED-CONTEXT?
the question
FALSE COGNATE OR DUPLICATED CONCEPT?
HEURISTICS
same meaning? ◆ SAME STRUCTURE? ◆ SAME relationships? ◆ SAME types?
SAME
One Model
MODEL EXPRESSION
DIFFERENT
two Models
BOUNDED-CONTEXT
FRONTEND
SEARCH
BackEND
SEARCH
=
?
CHANGE A STRUCTURING CONSTRAINT
How does each one move?
HEURISTICS
-
Keep the same structure, relationships, names → model expression
-
maps it to a ≠ concept or false cognate → bounded-context
Domain code
Model Expression
Outbound / Inbound
List<Journey>
Model Expression
UI
2 fixed fields
Add-a-destination control
Model Expression
2 fixed journeys
List of journeys
user Story
database
Model Expression
Keyed by bound
Keyed by position
Move an invariant to another expression.
Any functional impact?
Impact → the always-valid model breaks → 2 different bounded contexts
INVARIANT MIGRATION
HEURISTICS
B
A
?
Round Trip
has 2 adjacent journeys!
No impact → just a delegation → 2 expressions of 1 bounded context
HOW DO WE KEEP ONE EXPRESSION FROM CONTAMINATING ANOTHER?
Each expression is limited by its grammar
The grammars differ, and that is normal
preserving conceptual integrity
CREATE TABLE criteria (
search_id CHAR(36) PRIMARY KEY
REFERENCES search(id),
trip_type VARCHAR(16) NOT NULL,
direct_train_only BOOLEAN
);data class Criteria(
val tripType: TripType,
val directTrainOnly: Boolean,
)VALUE OBJECT
Service
Persistence
entity
Model Expression
Model Expression
EXTENDING THE ANTICORRUPTION LAYER
preserving conceptual integrity
Service
Persistence
VALUE OBJECT
entity
Model Expression
Model Expression
Criteria
Criteria
AntiCorruption Layer
DROP CRITERIA_ID

HEXAGONAL ARCHITECTURE
preserving conceptual integrity
Only when the grammars allow these expressions to be isomorphic:
exactly the same
THE CONFORMIST STRATEGY
preserving conceptual integrity
@Entity
@Table(name = "criteria")
data class Criteria(
@Id
@JsonProperty("criteria_id")
val id: UUID,
@JsonProperty("tripType")
val tripType: TripType,
@JsonProperty("directTrainOnly")
val directTrainOnly: Boolean,
)A grammar limit is not a tension
A tension is stretching an expression beyond what its grammar requires
MODEL TENSION HEURISTICS
preserving conceptual integrity
A model tension is a design smell
SQL
Criteria
{ID}
Grammar limit
CODE
Criteria
{ID}
Model Tension
Sometimes there is no way to prevent contamination
ex: Conway's Law applies every time
DRIVING FORCES
preserving conceptual integrity
In a well-applied hexagonal architecture, the domain is the driving force
Don't fight a driving force. Steer it.
Inverse Conway Maneuver pushes the homomorphism your way
User
Billing
Payment
Shopping
User
Billing
Shopping
Payment
FINAL VERDICT
KEEP EVERY EXPRESSION
SPEAKING THE SAME MODEL
The model is intangible. We only ever deal with its expressions.
CASE CLOSED
◆ Tactical Variance Authority ◆
conclusion
@julientopcu.com

JULIEN TOPçu
SENIOR ARCHITECT INQUISITOR

◆ Tactical Variance Authority ◆
CTO
COACH
socio-technical architect
VARIANT

Three SQL workarounds for a value-object Criteria
| Strategy | Cost | |
|---|---|---|
| Inline in parent | concept gone | |
| Inherited identity (shared PK) | column name lies, bytes are a search_id |
Every table requires a PRIMARY KEY. The grammar offers no neutral path.
Persistence
Tables
Surrogate Key Forger
ORM
@Id
@OneToOne
@JoinColumn
@Table
@Entity
@Entity
@Table(name = "search")
data class Search(
@Id
@JsonProperty("id")
val id: UUID,
@OneToOne
@JoinColumn(name = "criteria_id")
val criteria: Criteria,
)@Entity
@Table(name = "criteria")
data class Criteria(
@Id
@JsonProperty("criteria_id")
val id: UUID,
@JsonProperty("tripType")
val tripType: TripType,
@JsonProperty("directTrainOnly")
val directTrainOnly: Boolean,
)Serializer
@JsonProperty
Controller
@JsonProperty
@JsonProperty
@JsonProperty
Service
+ ORM
Serializer +
The contamination travels upstream
A constraint that originated in SQL's grammar reshapes the nature of criteria
@Entity
@Table(name = "criteria")
@JsonIgnoreProperties(ignoreUnknown = true)
@JsonInclude(JsonInclude.Include.NON_NULL)
data class Criteria(
@Id
@GeneratedValue(strategy = GenerationType.UUID)
@JsonProperty("criteriaId") // ← now the id ships on the wire too
val id: UUID,
@Enumerated(EnumType.STRING)
@JsonProperty("tripType")
val tripType: TripType,
@JsonProperty("directTrainOnly")
val directTrainOnly: Boolean,
)Train carries a Schedule
data class Train(
val number: String,
val schedule: Schedule,
// ...
)
data class Schedule(
val departure: OffsetDateTime,
val arrival: OffsetDateTime
) {
init {
require(arrival.isAfter(departure)) {
"arrival cannot precede the departure"
}
}
}arrival > departure, what could go wrong?
CREATE TABLE train (
...
departure CHAR(25) NOT NULL, -- '2026-10-25T02:30:00+02:00'
arrival CHAR(25) NOT NULL, -- '2026-10-25T02:45:00+01:00'
CHECK (arrival > departure) -- looks fine
);Looks fine. Compiles. Runs.
Autumn DST rollback
INSERT INTO train (..., departure, arrival, ...) VALUES (
...,
'2026-10-25T02:30:00+02:00', -- before rollback (CEST)
'2026-10-25T02:30:00+01:00', -- after rollback (CET)
...
);
-- ERROR: new row violates check constraint> is lex on strings. Prefix matches up to the offset: '+02:00' > '+01:00', so the row is rejected.
The constraint lies twice a year.
Example
Business capability discrepancy due to duplication of business logic in the front-end.
Example
Aggregate IDs as database auto-increments instead of functional IDs leading to a violation of essential unicity constraints.
CREATE TABLE search (
id CHAR(36) PRIMARY KEY,
criteria_id CHAR(36) NOT NULL UNIQUE REFERENCES criteria(id) ON DELETE CASCADE,
selection_id CHAR(36) REFERENCES selection(id),
-- ...
);
CREATE TABLE criteria (
id CHAR(36) PRIMARY KEY,
trip_type VARCHAR(16) NOT NULL,
direct_train_only BOOLEAN NOT NULL,
CHECK (trip_type IN ('ONE_WAY', 'ROUND_TRIP'))
);| id | selection_id | criteria_id | ... |
|---|---|---|---|
| search#1 | selection#1 | criteria#1 | ... |
Search
| id | trip_type | direct_train_only | ... |
|---|---|---|---|
| criteria#1 | ROUND_TRIP | true | ... |
Criteria
Criteria has its own identity and is independent from the Search!
Example
A presentation model expression using local date time, forcing the zoned persistence model expression to localize the dates to the Zulu timezone.
On this last point, model expressions may follow the CAP theorem.
Example
Only considering front-ends as model expressions of their back-end's model, when they should be bounded-contexts (PWA, SPA), will produce distributed Big Balls of Mud.
Example
BFF (Back-end for Front-end) as a smell for a hidden bounded context.
Miscontextualizing meta models
A meta-model can be used broadly amongst different model expressions, even when it doesn't make sense.
Meta-models, Socio
Spotify, Team Topologies, SAFE, SCRUM...
Meta-models, Technical
Tactical patterns, CRUD, CQRS, LLM / neuronal models, FC / IS.
Example
REST is a CRUD meta-model suitable for system affordance, but it is not always suitable for a domain core, as it may make the latter anemic.
Model Expressions: An Overlooked Aspect of Domain-Driven Design?
By Julien Topçu
Model Expressions: An Overlooked Aspect of Domain-Driven Design?
If you have ever noticed your front-end, back-end, and database each speaking a slightly different version of the business, you have already come across one of those gaps that quietly slip into our systems. These mismatches create inconsistencies that slow teams down and make even small changes feel heavier than they should. Behind this sits a simple reality: every layer of the socio-technical system ends up expressing the domain model in its own way. These are the model expressions. When they drift apart, pick up constraints from one another, or assume responsibilities they were never meant to hold, the bounded context starts losing cohesion and essential business behaviors become harder to preserve. That is when familiar symptoms show up: layers influencing each other in odd ways, rules applied inconsistently, or business intent fading as it passes through the system. In this talk, you will get a clear picture of what these model expressions are and how they relate to a bounded-context. We will explore how to shape a strategy that guides the way they interact so the system stays coherent over time. Through concrete examples of model-expression challenges, we will see how model tensions help spot design smells that erode the integrity of a bounded context.
- 64