Skip to content

Design Patterns — Athomic

Reference for which patterns are in use, where they live, and when to apply each one. Before implementing a feature, identify which pattern(s) apply. Patterns are not optional style choices — they are the correct solution to recurring structural problems.


Pattern vs Anti-Pattern: The Core Rule

A design pattern solves a recurring problem by establishing a proven structure. An anti-pattern is what you get when you solve the problem ad-hoc:

Anti-pattern Correct Pattern
if isinstance(x, A): ... elif isinstance(x, B): ... Strategy + Registry
Constructor with 10+ parameters Builder or Parameter Object
One giant class with 50 methods Facade + Extract Class
Direct new ConcreteClass() everywhere Factory Method
Copy-paste code with slight variations Template Method
Module importing from every other module Dependency Inversion + Protocol

Patterns In Use — With Their Location

1. Factory Method

What it solves: Decouples object creation from usage. Callers ask for an object by name, the factory decides which concrete class to instantiate.

Where it is:

ai/features/engines/factory.py          ← EngineFactory: "numpy" → AsyncNumpyEngine
ai/features/transformations/registry.py ← TransformationRegistry: "minmax" → MinMaxTransformation
ai/features/encoders/factory.py         ← FeatureEncoderFactory (5-step assembly)
database/factory.py                     ← connection_manager_factory
database/kvstore/factory.py             ← KVStoreFactory
integration/messaging/producers/factory.py
resilience/locking/factory.py           ← LockingFactory

The pattern in this codebase:

# ✅ CORRECT — factory + registry
class EngineFactory:
    def create(self, settings: EngineSettings) -> FeatureExecutionEngineProtocol:
        engine_cls = EngineRegistry.get(settings.engine)  # lookup by string key
        return engine_cls(settings)

# ✅ CORRECT — new engine registered without touching factory
EngineRegistry.register("ray", RayEngine)

When to apply: Any time you need to create objects whose concrete type is determined at runtime (from config, settings, or registry key).

Anti-pattern to avoid:

# ❌ WRONG — direct instantiation scattered everywhere
if settings.engine == "numpy":
    engine = AsyncNumpyEngine(settings)
elif settings.engine == "torch":
    engine = TorchEngine(settings)
# Adding "ray" requires modifying THIS code AND finding all other places

2. Registry

What it solves: Maps string keys to implementations. Combined with Factory, enables extension without modification (OCP).

Where it is:

ai/features/engines/registry.py                  ← EngineRegistry
ai/features/transformations/registry.py          ← TransformationRegistry
ai/features/resilience/adaptive_concurrency/discipline/registry.py
ai/features/resilience/adaptive_concurrency/policies/registry.py

Pattern:

class TransformationRegistry:
    _registry: dict[str, type[BaseFeatureTransformation]] = {}

    @classmethod
    def register(cls, key: str, transformation_cls: type) -> None:
        cls._registry[key] = transformation_cls

    @classmethod
    def get(cls, key: str) -> type[BaseFeatureTransformation]:
        if key not in cls._registry:
            raise KeyError(f"No transformation registered for key '{key}'")
        return cls._registry[key]

    @classmethod
    def register_defaults(cls) -> None:
        cls.register("minmax", MinMaxTransformation)
        cls.register("zscore", ZScoreTransformation)
        ...

When to apply: When the set of implementations is open-ended and should be extensible by adding new registrations rather than modifying existing code.


3. Strategy

What it solves: Encapsulates a family of interchangeable algorithms. The context uses the algorithm through a Protocol, not a concrete class.

Where it is:

resilience/adaptive_concurrency/algorithms/      ← AIMD, Gradient2 strategies
resilience/adaptive_concurrency/discipline/      ← FIFO, LIFO queue strategies
resilience/adaptive_concurrency/policies/        ← reject, probabilistic overload strategies
integration/messaging/consumers/strategies/      ← consumption, orchestration strategies
integration/messaging/producers/strategies/      ← direct, delayed strategies

Pattern:

# Protocol defines the algorithm interface
class QueueDisciplineProtocol(Protocol):
    def push(self, item: RequestSlot) -> None: ...
    def pop(self) -> RequestSlot: ...
    def peek(self) -> RequestSlot | None: ...

# Strategies implement the Protocol
class FIFODiscipline:
    def push(self, item: RequestSlot) -> None:
        self._queue.append(item)
    def pop(self) -> RequestSlot:
        return self._queue.popleft()

class LIFODiscipline:
    def push(self, item: RequestSlot) -> None:
        self._stack.append(item)
    def pop(self) -> RequestSlot:
        return self._stack.pop()

# Context uses Protocol — doesn't know which concrete strategy it holds
class AdmissionQueue:
    def __init__(self, discipline: QueueDisciplineProtocol) -> None:
        self._discipline = discipline

When to apply: When a class needs to support multiple variants of the same behavior, and those variants should be interchangeable at runtime.

Anti-pattern to avoid:

# ❌ WRONG — behavior switch via flag or isinstance
class AdmissionQueue:
    def pop(self):
        if self._mode == "fifo":
            return self._queue.popleft()
        elif self._mode == "lifo":
            return self._stack.pop()
        # OCP violated: adding "priority" requires modifying this class

4. Decorator (Structural)

What it solves: Adds behavior to an object by wrapping it, without subclassing. Each decorator adds one concern. The core is clean.

Where it is:

ai/features/encoders/decorator_stack.py     ← EncoderDecoratorStack
ai/features/encoders/telemetry.py           ← TelemetryInstrumentedEncoder
ai/features/encoders/lineage.py             ← LineageTrackingEncoder
ai/features/encoders/admission.py           ← AdmissionGuardedEncoder
ai/features/caching/proxy.py               ← CachedTransformation (wraps any transformer)

The stack in this codebase:

TelemetryInstrumentedEncoder     ← outermost: OTel spans + Prometheus
  └── LineageTrackingEncoder     ← OpenLineage governance events
       └── AdmissionGuardedEncoder ← pre-checks memory before allocation
            └── CachedEncoder    ← batch-level cache (optional)
                 └── BackpressureAwareEncoder ← throttle (optional)
                      └── FeatureEncoderService  ← core: no cross-cutting concerns

Pattern:

class TelemetryInstrumentedEncoder:
    def __init__(self, inner: FeatureEncoderProtocol) -> None:
        self._inner = inner  # the next decorator or the core

    async def encode_tensor_batch(self, raw_states) -> FeatureResponse:
        with self._tracer.start_span("feature.encode"):
            result = await self._inner.encode_tensor_batch(raw_states)
            self._histogram.observe(time.perf_counter() - start)
            return result

When to apply: Cross-cutting concerns (logging, metrics, caching, security checks) that should be composable and independent of the core logic.


5. Protocol (Structural Subtyping)

What it solves: Defines a contract without coupling to an implementation. Enables Dependency Inversion across layers.

Where it is: Every layer boundary in this codebase.

ai/features/engines/protocol.py             ← FeatureExecutionEngineProtocol
ai/features/transformations/protocol.py     ← TransformerProtocol
ai/features/imputation/protocol.py          ← FeatureImputationProtocol
database/kvstore/protocol.py                ← KVStoreProtocol
resilience/locking/protocol.py              ← LockingProtocol
integration/messaging/consumers/protocol.py ← ConsumerProtocol

Critical rule: The Protocol is defined at the lower layer. The higher layer implements it structurally (no inheritance needed).

# ✅ CORRECT — Protocol at the consumer's layer (lower level)
# ai/features/quality/protocol.py
class DriftBaselineProtocol(Protocol):
    def get_feature_stats(
        self, vector_id: str, feature_key: str
    ) -> tuple[float | None, float | None]: ...

# Higher layer implements it without explicit inheritance
class DistributedQualityBaseline:
    # Structurally matches DriftBaselineProtocol — no 'implements' declaration
    def get_feature_stats(self, vector_id, feature_key):
        ...

Anti-pattern to avoid:

# ❌ WRONG — importing the concrete class from a HIGHER layer
# observability/metrics/feature_metrics.py
from nala.athomic.ai.features.quality.protocol import DriftBaselineProtocol
# observability/ depends on ai/ — inverted layer order — DIP violation

6. Template Method

What it solves: Defines the skeleton of an algorithm in a base class, letting subclasses override specific steps without changing the structure.

Where it is:

ai/features/transformations/base.py    ← BaseFeatureTransformation
ai/features/transformations/async_base.py ← AsyncBaseFeatureTransformation
athomic/services/base.py              ← BaseService (lifecycle)

Pattern:

class BaseFeatureTransformation(Generic[T_in]):
    # Template method — defines the structure, delegates to _execute_transform_batch
    async def transform_batch(self, values: Sequence[T_in]) -> TensorResult:
        if len(values) >= MIN_BATCH_THRESHOLD:
            return await self._run_in_process_pool(values)
        return await self._run_in_thread(values)

    # Abstract step — subclasses provide the actual logic
    @abstractmethod
    def _execute_transform_batch(self, values: Sequence[T_in]) -> TensorResult: ...

When to apply: When multiple classes share the same algorithm structure but differ in one or two steps.


7. Facade

What it solves: Provides a simplified interface to a complex subsystem. Clients use the Facade; they never reach into subsystem internals.

Where it is:

athomic/facade.py                         ← Athomic (the composition root)
ai/features/service.py                    ← FeaturePlatformService (ai layer facade)

Pattern:

# ✅ CORRECT — clients use FeaturePlatformService, not internal encoders
service = FeaturePlatformService(settings)
response = await service.encode("user_risk_v1", raw_state)

# ❌ WRONG — reaching into internals
registry = service._registry  # bypasses the facade contract
encoder = registry.get("user_risk_v1")  # internal — may change
result = await encoder._engine.execute(plan, context)  # internal

8. Observer (Event-Driven variant)

What it solves: Notifies multiple subscribers when a state change occurs, without the publisher knowing who's listening.

Where it is:

integration/messaging/producers/       ← Kafka producers (event publishers)
integration/messaging/consumers/       ← Kafka consumers (event subscribers)
database/outbox/                       ← Transactional outbox pattern

The outbox variant:

# Publish event atomically with the business transaction (outbox stores to Mongo)
await outbox.append(event)
# Background worker reads outbox → publishes to Kafka → consumers react
# Publisher and consumers are fully decoupled

When to Choose Which Pattern

Situation Pattern
Need to create objects by string key Factory Method + Registry
Multiple variants of an algorithm Strategy
Adding cross-cutting concerns (logging, cache, auth) Decorator
Decoupling layers (import direction) Protocol (DIP)
Same algorithm structure, different steps Template Method
Simplify complex subsystem API Facade
React to events without tight coupling Observer / Event Bus
Group related config params Parameter Object (dataclass)
Build complex objects step by step Builder

Anti-Patterns Banned in This Codebase

Anti-pattern Why banned Correct pattern
isinstance chain (3+) OCP violation Strategy + Registry
TYPE_CHECKING import Hides broken architecture Move to correct layer
Direct new ConcreteClass() in business logic DIP violation Factory + Protocol
10+ parameter constructors Long Parameter List smell Config dataclass
God class (>800 lines) SRP violation Extract Class
print() in production Bypasses observability logger.*
Bare except: pass Swallows errors Specific exception + log

  • SOLID rules: docs/guides/architecture/solid-rules.md
  • Code smells: docs/guides/architecture/code-smells.md
  • Violations backlog: docs/backlog/todo/solid-violations.md