
Two parts of a system need to order work and put it into batches. Extracting a shared helper always looks like an obvious answer.
Then one caller needs a new eligibility rule. Another needs a capability flag. The helper gains a parameter, then a conditional, then another conditional. Before long, the code that was supposed to perform a common transformation also decides what several different domains are allowed to do.
Policy and mechanism separation gives us a better question than “can these methods share some code?” Who should own the decisions that code is making?
This is an architectural question. It arises wherever different domains reuse processing code without sharing every business decision.
I put together a small .NET example to support this article. The complete policy and mechanism separation sample is available on GitHub. The example uses ordinary C# rather than a rules framework, so the boundary is easy to inspect.
And while C# is used for this walkthrough, it’s not a requirement of the principle. The goal is not to invent a new pattern. It is to make an established principle easier to apply.
Reusing code is not the same as sharing decisions
Here, policy means the rules that determine what may participate. Mechanism means the processing that happens once that decision has been made.
In my example approach, in shipping a policy might require payment before dispatch or exclude refrigerated parcels when cold handling is unavailable. And for document processing, it might require consent or exclude a format the processor cannot handle.
Ordering selected items and forming bounded batches is a different responsibility. It does not need to know what payment, consent, refrigeration, or PDF support means.
This is, by all definitions, the Single Responsibility Principle, and I highly recommend reading Robert Martin’s explanation of it. His account is about the people and business functions that request changes, not merely how many things a method does. Code responding to different decision owners should not become coupled just because the implementations look similar.
The boundary matters when the next change arrives. In my example, adding support for refrigerated shipments is a shipping decision. It should not require a common planner to learn a new shipping-specific rule, and it should not change what document processing considers eligible.
That does not mean every rule must be decentralized. Some constraints legitimately have a common owner. It means reuse should not silently change who is accountable for a decision.
Domain-owned describes responsibility, not location. Rules may be evaluated in one process, another service, or cloud infrastructure. The boundary concerns who defines their meaning and authorizes changes, not where the code runs.
Two domains, one planning mechanism
The sample has two domain assemblies and one shared planning assembly:
Shipping rules -> eligible work items --\
-> shared planner -> batches
Document rules -> eligible work items --/
Both routes enter the same planner, but only after their own domain has selected the eligible scope.
| Component | Owns |
|---|---|
| Shipping | Shipment vocabulary, payment eligibility, cold-handling capability, parcel weight limits, and rejection reasons |
| Document processing | Document vocabulary, consent eligibility, format support, page limits, and rejection reasons |
| Shared planning | Its input contract, deterministic ordering, and capacity/count-bounded batching |
The following C# record declarations describe the capabilities and limits owned by each domain. They live in separate projects and are shown together here to make the difference visible:
public sealed record ShippingPolicy(bool CanKeepCold, int MaxParcelWeightKg);
public sealed record DocumentPolicy(bool SupportsPdf, int MaxPagesPerDocument);
These are not two instances of one universal business policy. They express different capabilities and different reasons for change.
That distinction also fits Martin Fowler’s explanation of bounded contexts that different parts of a system can legitimately have different vocabularies and models. We do not need to declare these small projects separate DDD bounded contexts to benefit from that idea.
The shared contract is deliberately smaller:
public sealed record WorkItem(string Id, int Units, int Priority);
public sealed record WorkBatch(IReadOnlyList<WorkItem> Items, int TotalUnits);
The planner needs an identifier, a capacity cost, and a priority. It does not need a shipment or document model.
Shipping maps kilograms to Units. Document processing maps pages to Units. The adapters own those meanings. The example runs separate plans and never combines kilograms and pages into one batch.
Let the domain select the work
Shipping evaluates its rules before invoking the shared planner. This excerpt is from ShippingPlanner.CreatePlan, after checking for null records, invalid measurements, and unknown temperature values:
ShippingRejection? reason = !shipment.IsPaid ? ShippingRejection.Unpaid
: shipment.Temperature == ShippingTemperature.Chilled && !policy.CanKeepCold
? ShippingRejection.ColdStorageUnavailable
: shipment.WeightKg > policy.MaxParcelWeightKg ? ShippingRejection.ExceedsWeightLimit
: null;
if (reason is { } rejection)
rejected.Add(new(shipment, rejection));
else
eligible.Add(new(shipment.Id, shipment.WeightKg, shipment.Priority));
The domain retains two outcomes: eligible work and valid records excluded by a domain-specific rule. Excluded records carry a shipping-owned reason rather than silently disappearing.
Only the eligible scope reaches the common mechanism:
var batches = WorkPlanBuilder.Build(eligible, batchWeightKg, maxParcelsPerBatch);
Document processing has its own selection code and rejection vocabulary. It checks consent, PDF support, and its individual page limit, then makes the corresponding call:
var batches = WorkPlanBuilder.Build(eligible, batchPages, maxDocumentsPerBatch);
The two adapters look similar. That is not, by itself, a reason to merge their business rules. A common shape can conceal different owners and different meanings.
Keeping business rules outside the planner does not mean the planner should trust everything it receives.
Its own contract requires:
- Non-blank, unpadded identifiers that are unique within a plan.
- Positive capacity costs that fit the supplied batch capacity.
- Non-negative priorities.
- Positive capacity and item-count limits, including when the eligible scope is empty.
The batching algorithm materializes and validates the selected items, orders them by descending priority and then ordinal identifier, and fills consecutive batches until another item would exceed either limit.
This is deterministic, priority-preserving next-fit batching. It is not an optimal bin-packing algorithm, and it can deliberately leave capacity unused. That is a choice made by the common mechanism, not a hidden domain policy.
The core rejects an oversized selected item rather than dropping it. A domain can consider a parcel eligible while the requested batch capacity is still too small. Both responsibilities need to hold.
There is another important limit: the planner validates the selected work, not every record in the original domain input. A record excluded before planning does not acquire a validation guarantee from the core. Each domain remains responsible for deciding what it must validate across its whole input.
Common enforcement and domain-owned decisions are complementary. Neither replaces the other.
A change to common ordering or batching can still affect every consumer. Keeping business rules with their domain owners removes one kind of coupling; it does not make changes to a shared library free.
When a straightforward implementation is enough
The rules in this example are small, typed, and maintained in code. Introducing a generic policy interface, expression language, or rules engine would make the demonstration larger without clarifying the ownership boundary.
That is not an argument against those tools.
Strategy is useful when callers need interchangeable implementations of an operation. Specification is useful when predicates or queries need named, reusable composition. This sample does not need either abstraction just to give an existing principle a name.
Microsoft RulesEngine provides a complementary example at a different level of tooling. Its documented JSON workflows and C# expressions let an application supply rules separately from the engine that evaluates them. That can be appropriate when external rule configuration is a real requirement.
Open Policy Agent’s philosophy makes the separation especially explicit: a domain-agnostic engine evaluates policies that the responsible owners can manage separately from the software. Its management model also shows why distributed evaluation and centralized policy management are different decisions.
The right question is not “can I add a rules framework?” It is “what problem would the extra machinery solve here?”
If callers no longer share the same transformation, keeping separate implementations or a narrow adapter can be better than preserving a shared helper through increasingly elaborate parameters. Sandi Metz’s discussion of the wrong abstraction is a useful counterweight to the reflex to consolidate everything.
A conditional is a reason to inspect the responsibility, not automatic proof of a design defect. The problem is a shared abstraction accumulating decisions with different meanings and different owners.
Review the owner of the next change
Before extending a shared helper, ask three questions:
- Who is accountable for this decision, and who should be able to change it?
- Is this a rule about which work may participate, or an invariant of how the common transformation operates?
- Can the owning domain change it without adding domain-specific knowledge to the shared mechanism or changing an unrelated consumer’s result?
A domain-specific change is not automatically safe, and a shared rule is not automatically wrong. The point is to make the boundary deliberate.
Reuse is a choice about implementation. Ownership is a choice about decisions. Extract the common processing logic without accidentally making both choices at once.
Discover more from James Croft
Subscribe to get the latest posts sent to your email.


