Julien Topçu
FOR YOUr EYES ONLY
◆ Tactical Variance Authority ◆
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
)
Sacred Model
Variants HUNT
suspects
WEAK SPOT
Aggregate
Value Object
1
1
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 |
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,
-- ...
);suspect#2
suspect#2
| id | selection_id | criteria_id | ... |
|---|---|---|---|
| search#1 | selection#1 | criteria#1 | ... |
Table
| id | trip_type | direct_train_only | ... |
|---|---|---|---|
| criteria#1 | ROUND_TRIP | true | ... |
Table
suspect#2
Variant
| id | selection_id | criteria_id | ... |
|---|---|---|---|
| search#1 | selection#1 | criteria#1 | ... |
Table
| id | trip_type | direct_train_only | ... |
|---|---|---|---|
| criteria#1 | ROUND_TRIP | true | ... |
| criteria#2 | ONE_WAY | false | ... |
Table
Criteria is still an Entity!
The Search doesn't know it contains Criteria!
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'))
);suspect#3
| id | selection_id | ... |
|---|---|---|
| search#1 | selection#1 | ... |
| search#2 | selection#2 |
Table
| id | trip_type | direct_train_only | ... |
|---|---|---|---|
| search#1 | ROUND_TRIP | true | ... |
Table
Variant
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.
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
Invariant
suspect#3
| id | bound | departure_station_id | arrival_station_id | ... |
|---|---|---|---|---|
Table
| id | trip_type | direct_train_only | ... |
|---|---|---|---|
Table
suspect#3
| id | bound | departure_station_id | arrival_station_id | ... |
|---|---|---|---|---|
| search#1 | OUTBOUND | PAR | LON | ... |
Table
| id | trip_type | direct_train_only | ... |
|---|---|---|---|
| search#1 | ROUND_TRIP | true | ... |
Table
suspect#3
| id | bound | departure_station_id | arrival_station_id | ... |
|---|---|---|---|---|
| search#1 | OUTBOUND | PAR | LON | ... |
| search#2 | INBOUND | BRU | AMS |
Table
| id | trip_type | direct_train_only | ... |
|---|---|---|---|
| search#1 | ROUND_TRIP | true | ... |
| search#2 | ONE_WAY | true |
Table
suspect#3
Variant
SQL is broken!
Tribunal order: Prune all of it!
verdict
SACRED MODEL
VARIANCE
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
suspect#4
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 }HTTP/1.1 422 Unprocessable Entity
Content-Type: application/json
{ "error": "Criteria can no longer be changed" }Request
response
◆ TVA Tribunal · Pruning Orders ◆
Verdict
EXHIBIT A
BUILT FOR
BUSINESS RULES
EXHIBIT B
BUILT FOR
STORAGE
TO PRUNE
EXHIBIT C
BUILT FOR
EXPOSURE
TO PRUNE
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
statement
An expression preserves the dimensions that match its grammar's concern, and loses the rest
01
Each expression is
02
Each grammar is
Constraints
EXHIBIT B
LOSES
BEHAVIOR
preserves
STRUCTURE
LOSES
WORKFLOW
preserves
RESOURCE STATE
EXHIBIT C
EXHIBIT D
LOSES
STRUCTURE
preserves
INTENT
one model, many Dimensions
one model, many Dimensions
User
Shopping
Payment
Billing
Social dimension
Model Expression
Model Expression
Model Expression
Model Expression
exploring The solution space
Speak a ubiquitous language within an explicit bounded-context.
— Eric Evans
What makes a bounded-context?
01
01
01
Model Expression
Model Expression
Model Expression
Model Expression
Intangible Model
As a traveller,
I want to search for a one-way or a round-trip,
so that I can find trains for my trip.
Model Expressions relationship
As a traveller,
I want to search for a multi-destination trip,
so that I can plan it in one search.
Model Expressions relationship
Model Expressions relationship
Model Expressions relationship
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()
}sealed class Criteria {
data class OneWay(val outbound: Journey) : Criteria()
data class RoundTrip(val outbound: Journey, val inbound: Journey) : Criteria()
}CREATE TABLE journey (
...
bound VARCHAR(8) NOT NULL,
PRIMARY KEY (criteria_id, bound),
CHECK (bound IN ('OUTBOUND', 'INBOUND'))
);Model Expressions relationship
CREATE TABLE journey (
...
position INT NOT NULL,
PRIMARY KEY (criteria_id, position)
);Model Expression
Outbound / Inbound
List<Journey>
Model Expression
Keyed by bound
Keyed by position
Model Expression
2 fixed fields
Add-a-destination control
Model Expression
2 fixed journeys
List of journeys
Result
Organizations which design systems are constrained to produce designs which are copies of the communication structures of these organizations
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
Model Expressions relationship
Model Expression
Model Expression
Model Expression
Model Expression
Intangible Model
The validity of this system of model expressions depends on their extrinsic cohesion
Do these journeys need to link up?
Model Expression
Model Expression
Model Expression
Round Trip
has 2 journeys ?
Round Trip
has 2 adjacent journeys!
BOUNDED-CONTEXT
Model Expression
Fare Computation:
Map Reduce
Algorithm
Model Expression
Model Expression
Model Expression
Fare Computation:
Map Reduce
Algorithm
Model Expression
Model Expression
Model Expression
Model Expression
Business
Logic
Model Expression
Business
Logic
Model Expression
Business
Logic
Model Expression
Business
Logic
Model Expression
class Search(
val id: UUID,
val criteria: Criteria,
// selection, trains, ...
)data class Criteria(
val tripType: TripType,
val directTrainOnly: Boolean,
// journeys...
)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,
)@Entity
@Entity
@OneToOne
@JoinColumn
@Table
@Table
@Id
@Id
@Table(name = "criteria")
data class Criteria(
val tripType: TripType,
val directTrainOnly: Boolean,
)@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
@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,
)@Entity
@Id
@OneToOne
@JoinColumn
@Table
@Entity
@Table
@Id
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,
)@JsonProperty
@JsonProperty
@JsonProperty
@JsonProperty
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
@JsonProperty
@JsonProperty
@JsonProperty
@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
challenges
challenges
Permeability
Model Expression
Model Expression
Model Expression
Inventory
SElection
Inventory
SElection
TGV 9612
72€
TGV 9612
72€
selection
TGV 9612
72€
TGV 9612
72€
TGV 9612
72€
Model Expression
Model Expression
Model Expression
Inventory
SElection
Inventory
SElection
TGV 9612
72€
TGV 9612
72€
selection
TGV 9612
72€
TGV 9612
72€
PUT
TGV 9612
72€
Model Expression
Model Expression
Model Expression
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
Model Expression
Model Expression
Model Expression
Inventory
SElection
Inventory
SElection
TGV 9612
96€
TGV 9612
72€
selection
TGV 9612
72€
PRICE CHECK
TGV 9612
72€
TGV 9612
72€
Model Expression
Model Expression
Model Expression
Inventory
SElection
Inventory
SElection
TGV 9612
96€
TGV 9612
72€
selection
TGV 9612
72€
PRICE CHECK
TGV 9612
72€
PUT
TGV 9612
72€
Model Expression
Model Expression
Model Expression
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
🏨
Hotel
Dossier
✈️
Flight
Dossier
🚗
Car
Dossier
🚆
Train
Dossier
◆ straight from the pain of the users ◆
Essential business concept
challenges
— Alberto brandolini
corollary
challenges
"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
},
]confusing bounded-context & expression
"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
},
]"legs": [ ... ],
"trains": [
{
"number": "A",
"bound": "OUTBOUND",
"selected": true
},
{
"number": "B",
"bound": "OUTBOUND",
"selected": false
},
]"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" }
}"journeys": [ ... ],
"trains": [
{
"number": "A",
"bound" : "OUTBOUND"
},
{
"number": "B",
"bound" : "OUTBOUND"
}
],
"selection": {
"OUTBOUND": { "trainNumber": "A" }
}Model Expression
Model Expression
the question
HEURISTICS
same meaning? ◆ SAME STRUCTURE? ◆ SAME relationships? ◆ SAME types?
SAME
MODEL EXPRESSION
DIFFERENT
BOUNDED-CONTEXT
SEARCH
SEARCH
?
HEURISTICS
Model Expression
Outbound / Inbound
List<Journey>
Model Expression
2 fixed fields
Add-a-destination control
Model Expression
2 fixed journeys
List of journeys
Model Expression
Keyed by bound
Keyed by position
Impact → the always-valid model breaks → 2 different bounded contexts
HEURISTICS
?
Round Trip
has 2 adjacent journeys!
No impact → just a delegation → 2 expressions of 1 bounded context
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,
)Model Expression
Model Expression
preserving conceptual integrity
Model Expression
Model Expression
AntiCorruption Layer
DROP CRITERIA_ID
preserving conceptual integrity
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,
)preserving conceptual integrity
Grammar limit
Model Tension
preserving conceptual integrity
User
Billing
Payment
Shopping
User
Billing
Shopping
Payment
FINAL VERDICT
CASE CLOSED
◆ Tactical Variance Authority ◆
conclusion
@julientopcu.com
SENIOR ARCHITECT INQUISITOR
◆ Tactical Variance Authority ◆
VARIANT
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.
Surrogate Key Forger
@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,
)@JsonProperty
@JsonProperty
@JsonProperty
@JsonProperty
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,
)Scheduledata 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
);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.
Business capability discrepancy due to duplication of business logic in the front-end.
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 | ... |
| id | trip_type | direct_train_only | ... |
|---|---|---|---|
| criteria#1 | ROUND_TRIP | true | ... |
Criteria has its own identity and is independent from the Search!
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.
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.
BFF (Back-end for Front-end) as a smell for a hidden bounded context.
A meta-model can be used broadly amongst different model expressions, even when it doesn't make sense.
Spotify, Team Topologies, SAFE, SCRUM...
Tactical patterns, CRUD, CQRS, LLM / neuronal models, FC / IS.
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.