The Contract That Became Someone Else's Migration
One published type from the Communication module is mapped as an owned entity into TimeManagement's schema - so adding a field to your own contract now needs a migration you do not own.
There is a question worth asking about every type you publish: what is the worst thing a consumer can do with it? Usually the answer is boring — they read a property, they persist a copy, they map it to their own model. Occasionally the answer is that they make your type part of their database schema, and at that moment your contract stops being a contract and becomes a shared table with two owners and one migration history.
Part 8 counted GroupFlights' thirteen published events and found seven of them talking to themselves. This part follows one published type — not an event, a plain model class — from the module that owns it to the schema it ended up in.
The type
Communication.Shared is the smallest contract project in the estate: an interface with two methods, a flags enum, and this.
public class Message
{
private Message()
{
}
public Message(string content, Guid? messageTemplateId = default)
{
Content = content ?? throw new ArgumentNullException(nameof(content));
MessageTemplateId = messageTemplateId;
}
public string Content { get; init; }
public Guid? MessageTemplateId { get; init; }
}
Communication.Shared/Models/Message.cs, GroupFlights at commit a19b337. Two properties and a guard. It is the Published Language of the Communication context: when any module wants a human to be told something, it constructs one of these and hands it over.
Two details in that class are load-bearing and neither is about messaging. The private parameterless constructor exists for one reason: an ORM needs to materialise the type without going through the public constructor. And init rather than set means the properties are write-once — also an ORM accommodation, since EF Core can set init properties during materialisation but application code cannot mutate them afterwards. A contract type in a contract project, shaped to satisfy an object-relational mapper.
The obvious question is: whose mapper?
Where it lands
Not Communication's. Communication has no database at all — it is the console-logging stub from part 5, and its schema list is empty.
TimeManagement.Core/Models/Deadline.cs imports GroupFlights.Communication.Shared.Models on line 1 and holds a Message property. Its EF configuration is four words long:
builder.OwnsOne(x => x.Message);
TimeManagement.Core/Data/EF/Configs/DeadlineConfiguration.cs:18. And the generated model snapshot spells out what those four words did:
modelBuilder.Entity("GroupFlights.TimeManagement.Core.Models.Deadline", b =>
{
b.OwnsOne("GroupFlights.Communication.Shared.Models.Message", "Message", b1 =>
{
b1.Property<Guid>("DeadlineId").HasColumnType("uuid");
b1.Property<string>("Content").HasColumnType("text");
b1.Property<Guid?>("MessageTemplateId").HasColumnType("uuid");
b1.HasKey("DeadlineId");
b1.ToTable("Deadlines", "time-management");
TimeManagement.Core/Data/EF/Migrations/TimeManagementDbContextModelSnapshot.cs:89-103. Read it slowly, because each line moves the ownership boundary.
OwnsOne("GroupFlights.Communication.Shared.Models.Message", …)— a type from another bounded context's contract assembly is now an entity type in TimeManagement's model. Its fully-qualified name is baked into the snapshot file, which is checked into TimeManagement's repository folder and diffed on every model change.b1.ToTable("Deadlines", "time-management")— the owned type is table-splitting into the owner's table.ContentandMessageTemplateIdare columns intime-management."Deadlines".b1.HasKey("DeadlineId")— the message's identity is the deadline's identity. It has no independent existence.
So: two columns in the time-management schema are the physical representation of a type declared in Communication.Shared.
What it costs, concretely
Suppose Communication wants to add a Locale property to Message — an entirely reasonable evolution for a notification contract, and one that changes nothing about Communication's own behaviour because Communication has no persistence.
That change requires a migration in the time-management schema. Somebody on the Communication side must know that, must find the module that owns the mapping, must generate the migration in TimeManagement.Core/Data/EF/Migrations/, and must get it reviewed by whoever owns TimeManagement. If they do not, the model snapshot and the database diverge, and EF's next model check fails at startup in an unrelated module.
The contract is a schema, and its owner does not know it.
That sentence is the finding, and it generalises far beyond this repository. OwnsOne is a lovely feature — it is exactly how you map a value object into its parent's table without a join — and nothing about it warns you that the type came from somewhere else. There is no analyser rule, no compiler warning, no review checklist item. The mapping line reads identically whether the owned type is yours or a third party's.
To be fair to the design, the alternative is not free either. TimeManagement could declare its own DeadlineMessage record and map from Message at the boundary — a translation layer, an anti-corruption layer in miniature. That costs a class, a mapper and a test, and buys the ability to evolve independently. In an eight-module teaching monolith with two developers, the translation would look like ceremony. In a system where Communication and TimeManagement are owned by different teams, the translation is the only thing standing between an innocuous contract change and a cross-team migration.
The rule I would extract: if you are going to persist another context's published type, own the shape yourself. Copy the two properties into your own record, map at the seam, and accept the duplication. The duplication is the point — it is what makes your schema yours.
Two more scars from the same missing governance
Message is the sharpest example but not the only one, and the other two are visible in a single line each.
The name collision. Both Sales and Postsale publish a deadline-requested event, and both declare a participant record beside it. Sales.Shared/IntegrationEvents/DeadlineRequestedIntegrationEvent.cs:13 and Postsale.Shared/IntegrationEvents/PostsaleDeadlineRequestedIntegrationEvent.cs:12 both read:
public record RequestedDeadlineParticipant(UserId UserId, Email Email);
Identical shape, two namespaces, and Postsale references Sales.Shared, so both are in scope wherever Postsale's mapper works. The consequence is the ninth line of Postsale.Application/EventMapping/DomainEventsRemapping.cs:
using RequestedDeadlineParticipant = GroupFlights.Postsale.Shared.IntegrationEvents.RequestedDeadlineParticipant;
A using-alias at the top of a mapper is a scar you can date. It says: two contract projects grew the same concept independently, nobody noticed, and the compiler eventually forced somebody to disambiguate. The two events differ in one field — Sales' carries a RequestedDeadlineSource for correlation and Postsale's does not, which is part 12's subject — so the duplication is not even a straightforward copy.
The twins in one assembly. And in Sales.Shared/Changes/, two files:
public record ReservationCostDto(Money TotalCost, Money RefundableCost);
public record NewTotalCostDto(Money TotalCost, Money RefundableCost);
Byte-identical shapes, same namespace, same project. ReservationCostDto appears on the reservation snapshot Postsale fetches; NewTotalCostDto appears on the change command Postsale sends back. Semantically the names are defensible — one is the current cost, one is the cost after the change, and naming a DTO for its role rather than its shape is a real style with real advocates. But nothing in the assembly records that intent, and a reader encountering both for the first time has to trace call sites to learn which is which.
Contract governance is three questions
None of the three findings in this article needed a tool to discover. They needed somebody to ask three questions at the moment a type was added to a .Shared project:
- Who consumes this? If the answer is “only us”, it is not a contract (part 8).
- Who persists this? If the answer includes anyone but the owner, the owner has silently acquired a migration dependency.
- Does this shape already exist? Two records with the same fields in the same assembly are a question, not necessarily a defect — but the answer belongs in a comment.
A review checklist with those three lines would have caught all of it, and none of them are the sort of thing an architecture-fitness test finds, because every one of them compiles perfectly. The contract surface is the one part of a modular monolith that no automated check protects, because there is nothing syntactically wrong with any of it.
Next, the estate's flagship decision, scored line by line: splitting a bounded context, with the ADR attached.