← Design patterns
Behavior Interchangeable policies

Strategy

Change the rule. Keep the workflow.

Your search page can show the best match or the most recent result. The records stay the same. The rule for choosing what comes first changes. Let’s give that rule a clear place to live.

TypeScriptGoPython Same behavior, across the comparison.

01 / The idea

One search. Two reasonable answers to “first.”

You’re building a documentation search. A response gives you a list of results with a relevance score and an age in days. At first, you copy the list, sort by score, and render it. That is a good place to start: one view, one short rule.

Then the page adds a “Recent” option. A command palette reuses the results, and it needs the same validation and tie handling. You can repeat a conditional in both views. The pressure appears when a new ordering rule also means checking that both copies still agree about everything else.

Strategy makes a variable algorithm or policy a collaborator that a consumer can call through a common contract. Here, the collaborator compares two search results. The consumer prepares the ordered list. The caller supplies the collaborator it wants.

The useful boundary is small: “tell me how these two records compare.” The consumer can keep copying the list and resolving ties without knowing whether the reader selected relevance or recent.

02 / See the shape

Pass a rule where you used to make a decision.

In the basic form, both policies accept two records. Relevance puts a larger score first; recent puts a smaller age first. The ranker copies the input and asks the supplied function how to order it.

In the wild adds the shared responsibilities: reject invalid records and use an ascending ID to break ties. It ranks the scores the search response already carries; computing how well a document matches a query happens before this code runs.

Follow policy from the call site into the comparison. The selector knows the available names. The ranker knows the contract. Adding another function does not require adding another branch inside the ranker.

Two ordering policies, one consumer. Each policy compares two records; the consumer copies and sorts the list. This small form assumes valid input and preserves incoming ties.

TypeScriptReading
ranking.ts
export type SearchResult = Readonly<{
	id: string;
	score: number;
	ageDays: number;
}>;

// Negative: a comes first. Zero: tied. Positive: b comes first.
export type RankingPolicy = (a: SearchResult, b: SearchResult) => number;

export const byRelevance: RankingPolicy = (a, b) => b.score - a.score;
export const byRecent: RankingPolicy = (a, b) => a.ageDays - b.ageDays;

// Small form: valid records assumed; ties keep the incoming order in JS.
export function rankBasic(items: readonly SearchResult[], policy: RankingPolicy): SearchResult[] {
	return [...items].sort(policy);
}
// Example: rankBasic(results, byRecent).
GoAlongside
ranking.go
type SearchResult struct {
	ID      string `json:"id"`
	Score   int    `json:"score"`
	AgeDays int    `json:"ageDays"`
}

// Negative: a comes first. Zero: tied. Positive: b comes first.
type RankingPolicy func(a, b SearchResult) int

func ByRelevance(a, b SearchResult) int { return cmp.Compare(b.Score, a.Score) }
func ByRecent(a, b SearchResult) int    { return cmp.Compare(a.AgeDays, b.AgeDays) }

// Small form: valid records assumed; stable sort preserves incoming ties.
func RankBasic(items []SearchResult, policy RankingPolicy) []SearchResult {
	result := append([]SearchResult{}, items...)
	slices.SortStableFunc(result, policy)
	return result
}

// Example: RankBasic(results, ByRecent).
The behavior every version promisesInputs, ties, and failures

Each record has a unique, nonempty ID using lowercase ASCII letters, digits, or hyphens; an integer score from 0 through 100; and an integer age from 0 through 365 days. These product rules keep the data easy to inspect.

Relevance means score descending; recent means age ascending. Both then compare IDs in ascending ASCII order. This gives the same result even if a response delivers tied records in a different order. IDs are identifiers, so locale-aware title sorting is a different requirement.

Every valid record appears once. The original list order is preserved. Empty input succeeds with an empty list. The caller selects a policy first, so an unknown name fails before record validation. Records are then checked in input order: ID format, duplicate ID, score, age.

The basic form intentionally leaves out validation and the ID tie rule; equal primary values retain incoming order in every basic example (each uses a stable sort, guaranteed for Array.prototype.sort since ES2019). Complete files include both forms.

Reading the TypeScriptFunction types, signs, shallow copies

RankingPolicy is a function type. A negative result puts the first argument earlier; a positive result puts it later; zero means a tie. For relevance, subtracting a.score from b.score puts a larger score earlier.

[...items] creates an array before sort changes its order. Its records still refer to the original objects. Readonly helps the type checker catch assignments, but does not freeze objects at runtime.

JavaScript numbers can be fractional or nonfinite, so Number.isInteger checks the numeric contract. This function still expects records of the declared shape. Validate arbitrary JSON before treating it as SearchResult[].

Reading the GoFunction values, slices, errors

RankingPolicy names a function signature. Go functions can be passed as values. cmp.Compare returns the comparison sign directly, without subtracting integers. The int fields already exclude fractional values.

A slice refers to a backing array; assigning it to another variable would still share that array. Appending the records to a new empty slice creates independent storage here. The struct contains only integers and strings, so changing a returned field does not change the caller’s field.

Selection and validation return an error. The caller checks it before continuing. Pass a non-nil, consistent policy.

Reading the PythonCallable policies and comparator keys

Python’s RankingPolicy is a callable type. Because sort accepts a key function rather than a two-record comparator, cmp_to_key adapts the strategy to the sorting operation. The policy still owns only the primary ordering rule; the ranker adds the common ID tie rule.

list(items) gives the ranker its own list order, while frozen, slotted SearchResult records remain safe to share. ValueError reports bad input or an unknown policy; Python’s dynamic values also let the checker reject fractions, non-finite numbers, and booleans for the integer fields.

03 / Follow the values

Before switching the rule, predict who moves.

Cache and api both score 90. Release scores 65 but is only one day old. Predict the first result for “Recent,” then change the policy. Next, give cache an age of 7 so it ties with api. Does arriving first in the input let it win the tie?

Same results. A different first choice.

Predict who leads, then change the policy or cache’s inputs. The returned order explains the decision.

Change cache’s values

Try score 95, age 1, or an invalid score of 101. Age comes from the caller’s response snapshot.

Caller selects relevance Ranker validates & copies Policy compares · ranker breaks ties
Input snapshot · original order
IDScoreAge (days)
cache9030
api907
release651

Returned order

  1. apiscore 90 · 7 days
  2. cachescore 90 · 30 days
  3. releasescore 65 · 1 day

api and cache tie on score. The shared ID rule places api first, regardless of incoming order.

The input above keeps its original order. Sorting happens on a new array.

Runs the TypeScript implementation shown above. Go is verified separately against the same cases.

The swapped policy changes which field matters. Validation, copying, and the ID tie rule still belong to the ranker. Notice that “interchangeable” means both policies meet those obligations; their purpose is to produce different orders.

04 / Try a decision

A policy swap should not surprise another view.

Separating behavior does not automatically protect the surrounding data. Trace a small implementation choice through two callers.

The search page and command palette share a cached array. Which TypeScript return keeps their ordering independent?

A teammate adds a new policy. Here, compare applies that policy and the ID tie rule; items is the mutable array in the cache. Only the returned array should be reordered.

05 / Give it a real job

The search view chooses. The ranker follows through.

Imagine a documentation app with a search page and a command palette. A fetch completes, or the reader changes the ordering control. The view takes the current response snapshot, selects a policy once, calls the ranker, and renders the returned records. A failed selection or validation goes to that view’s error state.

01 / Caller → supplies

Search view

Owns the selected mode, the response snapshot, rendering, and visible failure feedback.

02 / Context → delegates

Result ranker

Validates records, copies the list, calls the policy, and resolves primary ties by ID.

03 / Strategy → compares

Ordering policy

Answers which of two records comes first under one rule. It does not fetch or render.

Tomorrow, the product adds “Lowest score” to help review weak matches. Add a comparator and expose it in the selector and control. The ranker’s preparation stays the same. With duplicated inline sorting, you would inspect each view’s branch, copy step, and tie behavior again.

The branch has a useful home: choosePolicy translates an external choice. Strategy does not make the application forget that choices exist. It keeps that knowledge out of the code that can work with any suitable comparator.

Each invocation receives its own policy value. There is no shared “current strategy” variable for another request to change. The supplied policies are synchronous and stateless, so nothing needs cleanup.

In a frontend, the idea runs wherever a view hands a comparator to toSorted or sort and lets the array method do the ordering; the toolbox below starts there.

Where this search example stopsSnapshots, pagination, and async work

The score and age already exist when ranking starts. Age is relative to a common response snapshot; a long-lived view may need fresh data. Reading a clock or fetching a score during comparisons could change the answer partway through a sort.

Sorting one downloaded page cannot establish the global order of all matches. If the UI offers globally ranked, paginated results, the server must apply the selected ordering and deterministic tie rule before pagination. Filtering belongs before or after ranking according to an explicit product requirement.

If a future strategy calls a remote service, the contract changes: callers must account for latency, cancellation, and failure. Fetch required values first and then compare a fixed snapshot, or design an asynchronous ranking operation with those obligations visible.

06 / Already in your toolbox

You may already have passed the strategy yourself.

Sorting APIs expose this relationship at the call site: the library owns the sorting process, while the comparator you pass owns the ordering rule.

JavaScript’s sort and toSorted

Both accept a comparator. sort changes the receiver; toSorted creates a new array. Our copy-then-sort consumer makes the array boundary visible.

ECMAScript: sort and comparator contract ↗ECMAScript: toSorted ↗

Go’s slices.SortFunc

The caller supplies a signed comparison function and the library sorts the slice. Its contract requires consistent ordering and does not guarantee stability. Our practical example resolves primary ties by unique ID, so it does not rely on stability. The basic example uses SortStableFunc to preserve incoming ties.

Go: SortFunc and SortStableFunc ↗

Rust’s slice::sort_by

sort_by takes a comparator returning Ordering, owns the sorting operation, and preserves the initial order of equal elements. Its comparator must be a consistent total order; otherwise the result is unspecified or the call can panic.

Rust: slice::sort_by ↗

07 / The parts to watch

The function signature is only the start of the agreement.

A comparator must give consistent answers. Comparing a record with itself should produce a tie. Reversing a pair should reverse its ordering. If a comes before b and b before c, a must come before c. Random values, changing state, or side effects can break those rules.

Both built-in policies satisfy the same obligations: compare valid records synchronously, leave them alone, and return an ordering. The ranker checks input data, not the supplied function, so a new policy deserves cases for ties, boundaries, and consistency.

Shared types can hide different promisesSubstitution needs behavior

If one policy silently drops low scores, it no longer fills this comparator role. If another requires a field that some results lack, its precondition is stronger than the existing contract. Revise the boundary deliberately instead of treating a matching method name as enough.

A policy that returns zero for two distinct records is fine here; the ranker’s ID comparison resolves the tie. A comparator that returns NaN, mutates a record, or changes its answer during sorting violates our contract. These are programmer errors, separate from the sample’s handled input failures.

A configured strategy has a lifetimeClosures and objects

A relevance policy might eventually capture a fixed weighting configuration. A closure is enough for one operation; an object or interface can help if the policy has several related methods or owned resources. Name who creates it, how long configuration remains valid, and who cleans it up.

Keep request-specific state out of a shared strategy unless its concurrency behavior is intentional. For a sort, capture a fixed configuration before starting.

08 / Make the call

Which part needs to vary independently?

I’d keep one inline comparator when it explains a single view clearly. Strategy starts earning its place when different ordering rules need the same preparation, or different callers need to supply their own rule while sharing that preparation.

How a change affects local branching and a supplied policy
Tomorrow’s changeA local comparator or switchA supplied policy
One view always sorts by scoreThe rule sits next to its only use. Easy to follow.A separate selector and abstraction may add little.
Two views need the same tie and copy rulesRepeated preparation can drift as branches change.The shared ranker owns those obligations once.
Add a policy using existing fieldsAdd a branch wherever ordering is decided.Add the policy and its selection path. Preparation stays the same.
One mode requires a network callAn explicit separate operation may be clearer.The synchronous contract no longer fits; redesign it before substituting.

The count of if statements is not the decision. Look for a coherent policy that changes independently and a consumer that can use it without knowing its identity. If the consumer keeps inspecting which strategy it received, the shared contract may be missing something.

Other places this shape can be usefulOne role, different obligations

An export pipeline might accept a serializer: records in, bytes or an error out. A transfer pipeline might accept a compression policy. Both can have a stable surrounding workflow while the supplied behavior varies. Their contracts must also say how callers identify the output format and handle failure.

09 / Take the idea with you

Explain who chooses and who does the work.

“The search view supplies an ordering function. The ranker uses it while keeping validation, copying, and ties consistent.” That describes the design without needing the pattern name.

Try a transfer: add “Shortest title” ordering. Which input field is missing? Who should supply it? Which obligations stay with the ranker? Then find one conditional in your own code and decide whether it represents a replaceable policy or a clear local decision.

Connections to follow nextRelated lessons
  • Factory creates a value or collaborator. It can create a configured strategy; Strategy describes how a consumer delegates behavior to that collaborator.
  • Dependency injection describes how a collaborator is supplied. Passing this policy is injection; its replaceable ordering responsibility is the Strategy relationship.
  • State machines organize behavior around the current state and allowed transitions. Here the caller selects an ordering policy; comparing records does not advance a lifecycle.
  • Template method keeps an algorithm’s sequence in a base class and lets subclasses override steps. Strategy supplies a collaborator through composition.