Skip to content
kc@kumarChandrachooda.com:~$ cd /blog/make-the-draft-a-different-class && read --section="top" 0%
.NET

Make the Draft a Different Class

An offer draft and a published offer are not one entity with a status flag - they are two classes, and the transition is a method that returns the second one. What that buys, and what it silently drops.

By Kumar Chandrachooda 28 Nov 2025 5 min read
One box becoming a differently-shaped box, with the old operations left behind

Every system with a draft state eventually grows the same bug. Somebody calls AddLineItem on an invoice that was sent last Tuesday, or edits a purchase order after approval, and the fix is a guard clause: if (Status != Draft) throw. Then another one. By the time the entity has six operations and four statuses you have twenty-four guard clauses to keep consistent, and the compiler is helping with exactly none of them.

Part 2 graded GroupFlights' modules by how much domain model each one carries. Sales carries the most, and the first thing it does with it is refuse to play that game. An offer draft and a published offer are two different classes, and the transition between them is a method that returns the other one.

The transition is a return type

// src/Sales/GroupFlights.Sales.Domain/Offers/OfferDraft.cs:139-150
public Offer RevealToClient(IClock clock)
{
    var canBePresented = new CanVariantBePresentedToClient(clock);
    var anyVariantValid = _variants.Any(variant => canBePresented.Check(variant));

    if (anyVariantValid is false)
    {
        throw new CannotRevealOfferThatHasAllVariantsOverdue();
    }

    return new Offer(this, clock);
}

Six lines, and most of the design is in the signature. OfferDraft has AddNewVariant, ConfirmVariant and RemoveVariant — the cashier's workspace, where flight options are added, priced and withdrawn. Offer has AcceptVariant, RejectOffer and OnAcceptOfferDeadlineNotMet — the client's artefact, where a published set of options is chosen from or lapses. The two sets do not overlap by a single method.

Once RevealToClient returns, the caller is holding an Offer. There is no AddNewVariant on it. Not a guarded one, not a throwing one — the member does not exist, and a call to it is a compile error rather than a runtime exception. That is the whole trick: illegal operations become unrepresentable instead of guarded.

The same pattern appears again in the reservation half of the module. UnconfirmedReservation is the collecting phase; Reservation is the confirmed booking; and the transition is a domain service rather than a method, because it needs two collaborators:

// src/Sales/GroupFlights.Sales.Domain/Reservations/DomainServices/ReservationConfirmationDomainService.cs:21-42
public async Task<Reservation> Confirm(UnconfirmedReservation unconfirmedReservation,
    CancellationToken cancellationToken = default)
{
    var sourceOffer = await _offerRepository.GetOfferById(unconfirmedReservation.SourceOfferId, cancellationToken);
    var variantChosenByClient =
        sourceOffer.Variants.SingleOrDefault(v => v.AirlineOfferId == unconfirmedReservation.AirlineOfferId);

    if (variantChosenByClient is null)
    {
        throw new OfferDoesNotContainGivenVariant(unconfirmedReservation.AirlineOfferId);
    }

    if (unconfirmedReservation.CanConfirmReservation() is false)
    {
        throw new RequirementsNotMetToConfirmReservationException();
    }

    var availableCashiers = await _backofficeApi.GetAvailableCashiersUserIds(cancellationToken);
    var confirmInAirlinesDeadlineFactory = new ConfirmInAirlinesDeadlineFactory(availableCashiers);

    return new Reservation(unconfirmedReservation, variantChosenByClient, confirmInAirlinesDeadlineFactory);
}

Same shape, one level up: a guard, then a constructor call that produces the next type. Two aggregate pairs, one idea, applied consistently.

The constructor is the second gate

The interesting detail is that RevealToClient's check is not the only check. Offer's constructor re-applies it, and does something more:

// src/Sales/GroupFlights.Sales.Domain/Offers/Offer.cs:32-40
var isOverdue = new IsVariantOverdue(clock);
_variants = draft.Variants.Where(variant =>
        variant.HasBeenConfirmed &&
        isOverdue.Check(variant) is false)
    .ToList();

var entireOfferValidTo = _variants.MaxBy(variant => variant.ValidTo.ValidToForClient).ValidTo.ValidToForClient;

AcceptOfferDeadline = new Deadline(new DeadlineId(Guid.NewGuid()), entireOfferValidTo);

Three things happen in those lines, and they are worth separating.

  • The filter is a projection, not a validation. Unconfirmed and expired variants do not fail the transition; they are silently dropped from the new aggregate. A draft with five variants of which two are priced and current becomes an offer with two variants. That is almost certainly correct behaviour, and nothing announces it.
  • The whole-offer deadline is derived from the surviving variants — the offer stays valid until the last variant the client could still take. That value is knowable only after the filter, which is exactly why the constructor rather than the caller does it.
  • MaxBy on an empty sequence returns null, and .ValidTo on it throws. The guard in RevealToClient is what prevents that, and the constructor is internal rather than private, so anything else in Sales.Domain can construct an Offer directly and bypass the guard. The invariant is real; its enforcement depends on one caller behaving.

That last point is the honest reading of the pattern. State-as-type makes the operations safe by construction. It does not, by itself, make the transition safe — you still need a gate, and the gate is only as strong as the constructor's accessibility.

The reservation pair shows the same weakness with a sharper edge. Reservation's constructor re-checks its precondition, which is good practice:

// src/Sales/GroupFlights.Sales.Domain/Reservations/Reservation.cs:32-35
if (new EverythingProvidedToConfirmReservation().Check(reservation) is false)
{
    throw new RequirementsNotMetToConfirmReservationException();
}

But the domain service checked something stronger:

// src/Sales/.../Reservations/UnconfirmedReservation.cs:126-134
public bool CanConfirmReservation()
{
    if (MarkedOverdue)
    {
        return false;
    }

    return new EverythingProvidedToConfirmReservation().Check(this);
}

MarkedOverdue is checked in the caller's guard and not in the specification the constructor re-runs. So the aggregate's last line of defence is the weaker of the two — an overdue unconfirmed reservation constructed directly, from anywhere in Sales.Domain, becomes a confirmed Reservation. When you re-check a precondition inside a constructor, re-check the whole precondition, or the second gate quietly becomes a narrower one than the first.

The same class also has a status enum

Here is what makes Offer.cs worth reading twice: it uses both techniques at once.

// src/Sales/GroupFlights.Sales.Domain/Offers/Offer.cs:117-122
public enum CompletionStatus
{
    Accepted,
    Rejected,
    CanceledBecauseDeadlineNotMet
}

Having reached the published state by changing class, Offer then models how it ended with a plain nullable enum plus a guard method:

private void EnsureOfferNotCompleted()
{
    if (IsCompleted)
    {
        throw new OfferAlreadyCompletedException(this);
    }
}

AcceptVariant, RejectOffer and OnAcceptOfferDeadlineNotMet each call it first. That is the guard-clause approach the two-class lifecycle was avoiding — and here it is the right call. The three terminal states are terminal: nothing happens to an offer after it is accepted, so there is no post-acceptance behaviour to house in a fourth class. Splitting types buys you nothing when the type you split into has no methods.

The rule the file is teaching, without stating it: split into a new class when the operation set changes; use a status flag when only the history changes. Draft to published changes what you can do. Published to rejected changes only what happened. One file, both answers, and the contrast is the lesson.

What the pattern drops on the floor

OfferDraft has a property Priorities — the client's ranked preferences, validated on construction, carried through the whole drafting phase (OfferDraft.cs:49). Offer has no such property. The constructor copies six fields across (Offer.cs:25-30) and priorities is not one of them.

Downstream, the mapper agrees:

// src/Sales/GroupFlights.Sales.Application/Mappings/OfferMap.cs
private static List<PriorityChoiceDto> CreatePriorities(Offer offer)
{
    return new();
}

A method named for the thing it does not do, returning an empty list, because the source data no longer exists on the type it is handed. Nothing in the codebase records whether that was intentional — the client's priorities are arguably a drafting input rather than a published fact — but the DTO still has the field, so somewhere the intent was to show them.

This is the pattern's structural cost, and it generalises. Copy-construction between two classes is a manual field list, and a manual field list is a place data goes to die. The compiler that protected you from calling AddNewVariant on a published offer will not say a word about a property you forgot to carry over. With one class and a status flag, that field would simply still be there.

To be fair to the design: this is the cheaper of the two failure modes. A dropped display field is a bug you can see; a mutation applied to a sealed aggregate is a bug you find in production three months later. And the estate's own alternative is visible one method down — Offer.AcceptVariant writes Client.UserId = acceptingUser; through an internal set on a value object, mutating shared state in a way no return type would have caught.

Next, the encapsulation ledger — Postsale's aggregate exposes exactly one public member, and the next five parts follow what that costs.