Email verification as a separate bounded context

When a flag becomes an entity

Geschrieben von Timo Rieber am 1. Oktober 2025

Course registration in cloudapps needed email verification. The obvious move: add a verified boolean to Registration, store a token alongside it, check it on confirmation. Three fields, done.

I started listing what those three fields would actually need. A token. An expiry. Resend logic that generates a fresh token without touching the registration itself. Error states for expired tokens, already-confirmed addresses, invalid links. An email template and a gateway call. The list kept growing, and none of it had anything to do with course enrollment.

Not a flag

A Registration tracks enrollment: course, person, fee, payment state. Its state machine moves between reserved, confirmed, cancelled, and waiting list. Every field and transition serves that lifecycle.

Email verification has a different lifecycle entirely. It starts when a registration is created, but from that point it runs on its own clock: token generated, email sent, link clicked or expired, optionally resent with a corrected address. The registration doesn't care about any of those steps. It only cares whether the email was eventually confirmed.

Picture the flag approach under pressure. Resend with a corrected email address: now Registration needs to store both the original and the pending email, swap them on confirmation, and roll back on expiry. Token expired: you can't just reset verified to false, because that's also the initial state - you need a third state to distinguish "never attempted" from "attempt expired." Each edge case adds a field or a branch that has nothing to do with enrollment. The concern isn't growing into the entity. It's growing away from it.

What a lifecycle looks like

class EmailVerification(BaseEntity):
    email: str
    registration_id: UUID
    token: str
    expires_at: datetime
    confirmed_at: datetime | None = None

    def confirm(self, token: str) -> None:
        if self.is_confirmed():
            raise EmailVerificationError('Email already confirmed')
        if self.is_expired():
            raise EmailVerificationError('Verification link has expired')
        if self.token != token:
            raise EmailVerificationError('Invalid verification token')
        self.confirmed_at = clock.now()
Python

Three distinct error conditions in confirm alone - already confirmed, expired, wrong token. None of those states exist on Registration. The registration_id is a correlation key, not a foreign key. EmailVerification never imports Registration or anything from the event manager domain.

Five commands handle the full lifecycle: start, confirm, resend, query status, and a read model for the verification state. It has its own repository Protocol, its own SQLite table, its own migration. When a concern accumulates that much infrastructure, it's not a feature of the host entity anymore.

Where the contexts meet

Registration and verification connect through a domain event. When RegistrationCreated fires, an event handler picks it up, reads the registration's email through a query service, and delegates to the client portal service to start verification. The registration domain doesn't know verification exists. The verification domain doesn't import anything from registration - its registration_id field is just a UUID it received at creation. A direct import would have fused the two lifecycles back together, undoing the separation at the code level.

If the user corrects their email during resend, the confirmation command updates the registration's contact data through a service call, with data verification owns. Each context reaches the other only through events and services, never through shared state.

How the boundary proves itself

47 test cases across the verification module, and most of them don't need a registration at all. Domain tests create an EmailVerification directly and exercise the confirm/expire/token logic. Repository tests persist and query without any course or enrollment fixture. The acceptance tests wire both contexts together, but they're the minority. When most of your tests run without the neighboring context, the boundary is real - not an architectural diagram, but a measurable property of the code.

One commit, 30 files, 1587 lines. A lot for what started as "just add a verified flag."