A fluent builder API is a vocabulary. If that vocabulary is the same on every builder, the editor will offer WithCheckedBag on a ticket that cannot take hold luggage. You can throw at runtime. You can also make the method not exist.
The second option is a composition problem: name the capabilities a kind of ticket actually has, then build its public callback type from only those capabilities.
Name the capabilities
Each capability is one small interface. The type parameter is the public fluent surface that claims it, so every method hands back that same surface and the chain keeps going.
public enum Bag { Small, Large }
public interface ISeatCapability<out TSelf>
{
TSelf WithSeat(string seat);
}
public interface ICabinLuggageCapability<out TSelf>
{
TSelf WithCabinBag(Bag bag);
}
public interface ICheckedLuggageCapability<out TSelf>
{
TSelf WithCheckedBag(Bag bag);
}
A kind is an intersection
An economy builder has a seat and cabin luggage. A business builder has those and checked luggage. The public interface for each kind is the intersection of the capabilities it claims.
public interface IEconomyBookingBuilder :
ISeatCapability<IEconomyBookingBuilder>,
ICabinLuggageCapability<IEconomyBookingBuilder>
{
}
public interface IBusinessBookingBuilder :
ISeatCapability<IBusinessBookingBuilder>,
ICabinLuggageCapability<IBusinessBookingBuilder>,
ICheckedLuggageCapability<IBusinessBookingBuilder>
{
}
The concrete builders are internal. BookingDefinition creates one with a mutable booking draft, exposes only the appropriate public interface inside the callback, then materializes the draft when that callback returns. There is intentionally no terminal .Build() call: the enclosing definition owns that boundary.
Prove it
Valid — both callbacks compile and the definition materializes two bookings:
var definition = new BookingDefinition()
.Economy("MAD-LHR", builder => builder
.WithSeat("12A")
.WithCabinBag(Bag.Small))
.Business("MAD-JFK", builder => builder
.WithSeat("2A")
.WithCabinBag(Bag.Small)
.WithCheckedBag(Bag.Large));
$ ./scripts/verify.sh
Build succeeded.
0 Warning(s)
0 Error(s)
Economy MAD-LHR | seat 12A | cabin bags 1 | checked bags 0
Business MAD-JFK | seat 2A | cabin bags 1 | checked bags 1
Invalid — checked luggage is not in the economy callback's vocabulary:
definition.Economy("MAD-LHR", builder => builder
.WithSeat("12A")
.WithCabinBag(Bag.Small)
.WithCheckedBag(Bag.Large));
CompileContracts/InvalidEconomyBooking.cs(12,14): error CS1061:
'IEconomyBookingBuilder' does not contain a definition for 'WithCheckedBag'
Open the compiler-verified sample. Install the .NET 10 SDK first — the sample is a console app. The same CI command builds and runs the valid path, then enables the excluded negative fixture and requires that specific CS1061 failure.
Nothing was disabled and nothing throws. The member is absent from the callback's static type, so the editor never offers it and the compiler refuses to bind it. The exception class for “checked bag on economy” was never written, because there is no such operation to implement.
Why this beats a flag
The usual alternatives keep one type and move the rule somewhere the developer cannot see it while typing:
- A flags enum or a
Kindproperty. Every method exists on every ticket;WithCheckedBagchecks the flag and throws. The editor still offers it. - A base builder with everything. Same problem, and every new kind inherits methods it has to reject.
- Validation in the constructor of the result. Correct rule, wrong audience. The first reader of “you can't do that” is a stack trace in someone else's CI run.
Composition inverts it. A capability is an interface; a kind is the intersection of the capabilities it claims; the compiler does the rule-checking and the editor becomes a filtered view of the valid vocabulary. Adding a kind means listing its public capabilities and implementing that contract internally. Adding a capability means claiming it only on the kinds that have it. Renaming a capability method becomes a compile-visible change for every builder that composes it. Tests stop asserting “this method throws for this kind” — the assertion is that the method is not there.
This is capability composition, not a staged typestate builder: calls do not transition the object through successive state types. Each builder's fixed public type selects the vocabulary available inside its callback. It is related to the broader idea of making illegal states unrepresentable, but it makes one narrower promise: illegal calls are absent.
What stays at runtime
The compiler only reviews what the type can express.
- Values.
WithSeat("12A")type-checks whether or not seat 12A exists on the aircraft. - Cross-object rules. A bag over the weight limit for this fare is a fact about two objects, not a member.
- Name clashes and references. Two bookings for the same passenger, a seat already taken — resolved when the objects meet, not when the chain is typed.
- The wire boundary. Anything that arrives as JSON has no compiler in front of it. The same rules run again there, as explicit errors.
The pattern moves unsupported member calls into the compiler’s domain. Argument values and relationships between objects still need runtime validation.
Apply it in your own domain
Look for operations that belong to some roles but not others: publishing on an editable document, adding a join to a relational query, or choosing delivery options for a physical order. Give each operation a small capability interface, then compose the public roles from the capabilities they support.
The important boundary is the callback type a caller receives. Keep the implementation behind that boundary, preserve the public role through each chained method, and let the enclosing object own materialization. The result is an API whose vocabulary teaches its constraints while you type.
Start with the flight-booking sample: add a capability, choose which booking role exposes it, and add a compile contract for the role that must not.