6 Composition and Dispatch: Variants, Operations, and Outcomes
6.1 Alternatives belong in the representation
A product combines fields: a task has an identifier, a request, and resource limits. A sum describes alternatives: execution finishes, is rejected, is cancelled, or fails with diagnostics.
Products are familiar because almost every record-shaped class is one: a Task contains an identifier and a request and resource limits. Sums are less visible in conventional object-oriented notation even though domains are full of them: a source is vector tiles or raster tiles or inline features; a response is success-with-a-value or failure-with-diagnostics. The two constructions answer different questions. Adding optional fields to a product does not turn it into an honest sum; it merely asks validators and consumers to recover the missing alternative structure.
Flattening alternatives into one record produces familiar warning signs: mode flags that reinterpret other fields, mutually exclusive nullable properties, and repeated switch() statements. When alternatives have different meaning, fields, or behavior, represent that distinction directly.
For example, a record with is_remote, nullable url, nullable data, and a type string invites rules such as “exactly one of url and data is set” and “ignore url unless is_remote.” A RemoteSource(url) / InlineSource(data) family makes the same facts structural. Similarly, a single status record with nullable value, reason, and error asks every consumer to repeat a switch(status) and remember which fields are legal. Concrete outcome variants move that switch into dispatch and give each alternative only the state it can actually carry.
S7 provides several related mechanisms, but they do different jobs.
6.2 Five mechanisms, five responsibilities
6.2.1 Unions state accepted alternatives
S7::new_union() describes which classes a position accepts. Its members may be primitive classes, NULL, or domain classes:
CandidateSelection <- S7::new_union(
FixedCandidate,
CandidateSet
)The union says that a property accepts either intent. It does not create a runtime variant, define shared fields, provide a dispatch target, or prove that all possible alternatives have been listed forever. Dispatch sees the class of the value, not “the union.”
Member order can also affect the prototype used for an unset property, so a union is not a neutral defaulting mechanism. Chapter 3 discusses that union-default caveat together with the separate questions of nullability, optionality, and default policy.
This candidate example preserves a distinction that a numeric vector loses: one fixed tuning value and several values to search are different requests even when both happen to contain one number.
6.2.2 Concrete classes preserve variant identity
FixedCandidate and CandidateSet are nominal values that can carry different invariants and methods. Use concrete variants when callers or operations must retain which alternative was chosen. A constrained string is enough when alternatives differ only by a tag and never by data or behavior.
6.2.4 Coverage tests check the family you claim to support
If a package publishes a known set of variants, keep that set explicit and test each required operation against it. The test is evidence about the registered family at that revision, not language-level exhaustiveness.
Open families need a conformance suite for extension classes. Closed-by-policy families need tests that fail when a new internal variant lacks an operation. Neither convention turns the hierarchy into a sealed type.
An open family also needs an evolution policy. Introduce a new verb with a loud parent fallback before making it required, publish the conformance suite for extension packages to run, and version the extension contract when a required operation or law must change. A third-party class can continue to construct after the protocol has evolved while lacking the new behavior; construction success is therefore not compatibility evidence.
6.2.5 Fallbacks reject unsupported runtime cases
A method on an abstract parent or broad class can issue a clear error when no specific implementation exists. This improves failure behavior. It is not coverage: a fallback can make every call dispatch while every new variant is still unsupported.
Keeping these roles separate prevents a fallback from being described as exhaustiveness, a parent from being described as a union, or a union from being treated as a domain value.
6.3 Define the operation before its methods
A generic is not a contract merely because it has a name. Define each operation in five parts:
- Meaning: what domain verb does it represent?
- Accepted concepts: which argument roles participate in dispatch, and which values are admissible?
- Result: what class or outcome family is returned?
- Failure policy: which failures are conditions, diagnostics, or result variants?
- Laws: what relationships must all methods preserve?
For example, render(model, dialect) might have this contract:
- render a semantic model without changing it;
- accept a
Modeland aDialect, dispatching on both; - return a
RenderedDocument; - signal an unsupported model–dialect pair as a classed condition;
- preserve model meaning under the documented parse/render equivalence.
Methods then implement that contract for (SelectModel, PostgresDialect), (SelectModel, SQLiteDialect), and other supported pairs. Multiple dispatch is appropriate because neither argument alone owns the interaction. It avoids placing a dialect switch() inside every model or a model switch() inside every dialect.
Coverage is a separate artifact from dispatch. Keep the supported model–dialect matrix explicit, test that every published pair has a specific method, and test that unsupported pairs reach the documented fallback. Adding a model, adding a dialect, and adding support for one pair are three different changes. In an open contract, a broad fallback keeps failure clear while a conformance suite tells extenders which pairs and laws they have actually implemented.
The operation should evolve before the method table does. A change from “render without semantic loss” to “render with target-specific approximation” changes the contract even if every method still returns a RenderedDocument. Name the approximation policy, diagnostics, and equivalence relation explicitly; otherwise method coverage can conceal semantic drift.
Single dispatch remains the usual choice when one concept controls the variation. Ordinary functions remain preferable when there is only one behavior and no extension point.
6.4 Nouns and verbs expose each other’s gaps
Representation and operations should be designed together.
- If
prepare()keeps asking whether a numeric vector means “fixed” or “search these,” the model is missing candidate intent. - If
execute()branches on several nullable fields to infer what happened, the model is missing task-outcome variants. - If
render()branches on both model and dialect tags, the design is missing a useful multiple-dispatch axis. - If a proposed class has no invariant, independent lifetime, or operation, it may be a property contract rather than a domain concept.
This is an iterative process: sketch representative classes and verbs, use the verbs to find missing distinctions, and extract repeated property machinery only after the distinctions are understood.
6.5 Composition over inheritance
“Favor composition over inheritance” is primarily a warning about using a hierarchy for implementation reuse. A parent chosen merely to donate fields or helper behavior creates a nominal claim that dispatch will later treat as meaningful. It also couples children to parent details and forces independent dimensions into one tree, leading to combinations such as ColoredBorderedScrollableWindow.
S7 avoids some classic fragile-base-class mechanics because parents chiefly contribute property schema and dispatch positions, but the design rule remains. Inherit when a value honestly is a member of the parent concept and substitutability is useful: VectorSource is a Source. Compose when a value has a collaborator or an independent axis: a style has sources and layers; a shape has styling. Do not invent a parent because two classes happen to share property names.
Healthy hierarchies are consequently shallow: often one abstract concept and one level of concrete variants. An intermediate parent must pay twice—by adding honest shared schema and by being a useful dispatch target. Primitive inheritance has the same honesty test: subclass character only when the domain value really is text, not when squeezing a structured object into a string happens to be convenient.
6.6 Outcome families describe what happened
Suppose task execution can produce:
TaskCompleted, carrying a value and measurements;TaskRejected, carrying policy reasons;TaskCancelled, carrying cancellation metadata;TaskFailed, carrying expected operational diagnostics.
These classes remove flag and NULL interpretation from consumers. They also separate domain outcomes from R conditions. Invalid arguments and programmer errors may still signal conditions; a designed adapter can turn selected operational conditions into TaskFailed at the execution boundary.
An outcome family alone does not define composition. It says what happened to one task, not how a sequence of tasks behaves.
6.7 Composition is a separate protocol
For every outcome pipeline, decide:
- whether the first failure short-circuits;
- whether independent failures accumulate;
- which warnings and diagnostics survive success;
- what
map()does to a successful payload; - what
and_then()does when the next step also returns an outcome; - how a collection of outcomes is sequenced;
- where conditions become values and values become conditions.
A deployment pipeline may short-circuit because later stages depend on earlier ones. A batch evaluator may accumulate every task outcome because partial completion is useful. Both can use the same variant vocabulary while implementing different composition protocols.
Laws make the protocol reviewable. For a short-circuiting protocol, mapping identity should not alter an outcome, mapping over a failure should preserve that failure, and sequencing should not execute steps after the first terminal result. For an accumulating protocol, result order and diagnostic combination require their own laws.
The resultr package is a useful public example of S7 variants paired with short-circuiting combinators. Its design is evidence for that particular protocol, not a requirement that every domain outcome use the same one.
Its small worked trace shows why the representation matters. Begin with a Success carrying parsed input. and_then() extracts that payload for the next fallible step; another Success continues, while a Failure becomes the pipeline value. The Failure method for and_then() simply returns the failure unchanged, so later steps are not run and the original diagnostic is preserved. wrap_in_result() is the deliberate adapter that catches selected conditions and turns them into variants. The caller then unwraps or branches once at the edge rather than scattering tryCatch() and status checks through every stage.
That protocol is excellent for dependent stages, but it would be wrong for a batch importer that must retain every row’s result. The batch can use the same Success/Failure vocabulary while defining sequence() to accumulate all outcomes and diagnostics. A validation protocol may instead accumulate independent failures before constructing anything. Variant classes describe the alternatives; map, and_then, and sequence laws describe how those alternatives compose.
6.8 Interfaces are operations plus laws
An interface is the set of generics a class supports together with behavioral laws those methods satisfy. Method registration can check the first part; only tests can establish the second.
For a renderable model, a conformance suite might check that:
- every published model–dialect pair has a specific method;
- unsupported pairs fail through the documented fallback;
- rendering is deterministic under canonical equality;
- parse-after-render preserves the promised semantic equivalence;
- rendering does not mutate the model.
For open extension families, publish that suite so extenders can run it. Adding a required generic changes the extension contract even when existing classes still construct successfully.
6.9 Encapsulation and indirection
R’s openness makes the exported surface a convention rather than an access barrier. Constructors, generics, and invariant-preserving transformations are the supported front door. The guarantees in Chapter 4 are promises about that door, not about arbitrary internal modification.
This is Parnas-style information hiding: hide decisions likely to change, not data out of a desire for secrecy. If property layout is expected to evolve, expose stable generics rather than making documented @ paths the primary interface. When another ecosystem requires $ access, S3 behavior, or a legacy shape, put a thin facade in front of one S7 source of truth. Duplicating mutable state on both sides creates two authorities and defeats the facade.
Dispatch ownership clarifies plugin design. When your package owns a generic and another package owns a class, registration is an extension point: a provider package can add its class and methods without editing the generic’s package. When your package owns the class and another ecosystem owns the generic, registration is integration. S7::super() makes intentional parent delegation explicit instead of relying on an implicit next-method search. The public sqlr design illustrates the two-dimensional case: render_sql(x, con) varies with both the semantic object and the connection dialect, so one method owns each meaningful pair.
Indirection earns its place when it absorbs a real variation:
- a generic absorbs behavior across meaningful classes;
- multiple dispatch absorbs a genuine interaction between concepts;
- a semantic class preserves intent that a primitive would erase;
- a projection absorbs change between a model and an external form.
Do not require three instances before naming an important domain concept. Apply repetition thresholds to generalized factories and framework machinery, not to distinctions such as candidate intent or task outcome that consumers already need.
Every layer should nevertheless pay rent by absorbing a real axis of variation. A generic pays by supporting meaningful behavioral variation or a real extension contract. A semantic class pays by preserving intent and laws a primitive would erase. A projection pays by isolating transport churn. A wire-name map pays by keeping foreign naming out of the domain model.
A wrapper with no law or operation, an interface with one implementation and no extension pressure, or a factory abstracting one construction does not pay that rent. Parnas’s hiding, facades, and “composition over inheritance” are not demands for more layers; they are tools for placing the few layers whose variation and authority can be named.