Skip to content

Branding requirement taxes leaf values, and the brands leak into consumers #73

Description

@btravers

Problem

DomainFieldMustBeBrandedOrAnEntity requires every field to be branded or an entity. For fields with real domain identity that is a good trade. For leaf values it is ceremony, and the brands it forces do not stay inside the domain — they leak into every consumer of the derived schemas.

What happened

Migrating a ~18-field aggregate to 0.7.0, the rule produced brands that carry no meaning:

const LabelSchema       = z.string().brand("EndOfRentalManagementMissionLabel");
const LostToBrandSchema = z.string().brand("LostToBrand");

Nothing is ever mixed up with a label. There is no second string it could be confused with, no invariant riding on it, no function taking two of them in a row. The brand exists only to satisfy the constraint. Meanwhile the brands that are worth having — the id, the account id, the agency id — are diluted by sitting next to half a dozen that are not.

The leak is the sharper half. Contract schemas derived from the entity carry the brands outward, so downstream consumers that never opted into the domain model start failing to typecheck against values they construct legitimately. In our case this surfaced at the GraphQL gateway and had to be resolved by parsing at that boundary — a real design decision forced by a constraint on a field whose brand means nothing.

Worth noting the asymmetry, since it shapes any fix: zod branding affects the output type only, so z.input stays unbranded and callers building input are unaffected. Everything reading .output — resolvers, mappers, test fixtures — is affected.

Proposal

Either of these would work; the first is smaller:

Per-field opt-out. The flags argument already exists and is required:

Entity.field(z.string(), { opaque: false })   // "this leaf needs no nominal identity"

Or internal-only brands. Auto-brand unbranded leaves inside the entity so the nominal typing still holds within the aggregate, but strip the brand from the derived input/output schemas so it never escapes to consumers.

Both keep the property the constraint is defending — you cannot pass a LessorAccountId where an AgencyId belongs — without taxing values that have no identity to protect.

Counter-argument, stated fairly

The rule is defensible as-is: it is a forcing function toward modelling every value, and an escape hatch will be reached for reflexively. If the answer is "brand everything, that is the point," documenting the leak into consumers and the recommended boundary treatment would still be worth doing — that part was a surprise, not a decision.

Found while migrating a production real-estate service to 0.7.0.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions