Module 6

Composition & DI without a framework

“Dependency injection” sounds like it requires a framework. It doesn't. At its core it is one discipline: no component constructs its own collaborators. Every class in the beta service receives what it needs through its constructor — the calculator gets the store and cache handed to it, the diagnostics endpoint gets the service handed to it, and so on. A class that never calls new on its dependencies can be handed anything that satisfies the type — including a fake in a test.

But if nobody constructs their own collaborators, somebody has to construct everything. That somebody is the composition root: exactly one place in the codebase where all the new calls live, in dependency order. This module walks through the real one, plus the two patterns that make the design extensible: the strategy pattern and a named registry.

🎛 Registry & strategy demo

What the composition root wires — one BetaService per registered calculator

BetaService

"rolling"

BetaService

"reference"

PairCache (shared, ×1)

all 2 services share this one cache

PriceStore (immutable)

This mirrors EstimatorRegistry and BetaApplication.from in the Java service: names are trimmed and lowercased, blanks and duplicates are rejected with the exact error text shown above, and unknown lookups list the registered names. Registering a calculator is all it takes to become selectable end-to-end — the composition root wires one BetaService per name, and because they all share the single PairCache, extra calculators cost no extra aligned-pair memory.

The composition root: service registration without a container

BetaApplication.from is the one place where the object graph is assembled. Read it top to bottom and you read the architecture in dependency order — quality analysis first, then the immutable store, then one shared pair cache, then a BetaService per registered calculator, then diagnostics:

DataQualityAnalyzer.Report report = DataQualityAnalyzer.analyze(loaded, thresholds);
QualityIndex quality = QualityIndex.from(report);
PriceStore store = PriceStore.build(dedupeKeepFirst(loaded.records()));
PairCache pairCache = new PairCache(store, maxCachedPairs);

Map<String, BetaService> services = new LinkedHashMap<>();
for (String name : estimators.names()) {
    services.put(name, new BetaService(store, pairCache, estimators.get(name), windowConvention));
}
estimators.get(defaultEstimator); // validate early, with the registered names in the error
String defaultKey = defaultEstimator.trim().toLowerCase(Locale.ROOT);
BetaDiagnostics diagnostics = new BetaDiagnostics(services.get(defaultKey));

From core-api/src/main/java/com/schonfeld/betaservice/api/BetaApplication.java.

This is service registration without a container: the wiring is explicit, typed (the compiler checks every edge of the graph), and in exactly one place. Every embedder — the HTTP layer, the tests, a future REST framework — obtains services from this same surface. Notice two production touches: all services share the single pair cache, so registering more calculators costs no extra aligned-pair memory; and the default estimator is validated at startup, so a typo in configuration fails at boot with a helpful error, not at 3am on the first request.

The strategy pattern: one tiny interface

The service layer never knows which beta math it is running. It depends only on this interface:

public interface BetaEstimator {

    /**
     * Betas for each common-calendar day index in
     * [firstOutputIndex .. lastOutputIndex] (both inclusive).
     */
    double[] calc(PairSeries pair, int firstOutputIndex, int lastOutputIndex, int windowPriceDays);
}

From core-api/src/main/java/com/schonfeld/betaservice/calc/BetaEstimator.java (Javadoc abridged).

The flat rolling-OLS window is one strategy among several used in practice; the class Javadoc says it directly — an exponentially weighted (EWMA) variant slots in behind this same interface without touching the service layer. One method, plain arrays in and out: small interfaces are what make strategies cheap to add.

The registry: a named extension point with helpful failures

EstimatorRegistry maps names to strategies, and it is the entire extension surface: registry.register("ewma", new EwmaEstimator(0.94)) makes a new calculator selectable end-to-end — the HTTP layer resolves estimator= against it, no other layer changes. The details are where the production polish lives:

String key = normalize(name); // trim + lowercase
if (key.isEmpty()) {
    throw new IllegalArgumentException("estimator name must not be blank");
}
if (byName.putIfAbsent(key, estimator) != null) {
    throw new IllegalArgumentException("estimator '" + key + "' already registered");
}
public BetaEstimator get(String name) {
    BetaEstimator estimator = byName.get(normalize(name));
    if (estimator == null) {
        throw new IllegalArgumentException(
                "Unknown estimator '" + name + "'. Registered estimators: " + names());
    }
    return estimator;
}

From core-api/src/main/java/com/schonfeld/betaservice/calc/EstimatorRegistry.java.

Names are case-insensitive (“ ROLLING ” resolves to rolling), blanks and duplicates are rejected at registration time, and an unknown lookup doesn't just say “not found” — it lists what is registered. Error messages that name the valid options turn a support ticket into a self-service fix. Try all three behaviors in the demo above.

The layering rule — and why a lambda can be an estimator

There is a DI container in this project — Guice — but it lives only in the deployable assembly, the module that also owns the HTTP server. The core library stays framework-free: plain constructors, no annotations, no container types on any signature. The composition root makes migration mechanical — the Javadoc notes that each accessor translates into a bean definition one to one — but the engine itself never has to know.

That seam is also what makes the engine trivially mock-testable. Because BetaEstimator is a single-method interface injected through a constructor, a lambda can be the estimator in a unit test — hand the service (pair, first, last, w) -> fixedBetas and you can test caching, windowing and the HTTP layer with the math completely stubbed out. No mocking framework required: the design is the mock support.

Things to try

  • • Register ewma in the demo — watch a new BetaService box appear, wired to the same shared PairCache.
  • • Register  EWMA  (with spaces and capitals) again — it normalizes to the same key and is rejected as a duplicate, with the Java error text.
  • • Look up  ROLLING  — case-insensitive resolution finds rolling.
  • • Look up a name that doesn't exist — the error lists every registered estimator, exactly what a confused API caller needs.