HubAI
Back to Engineering
Design Patterns

How Classical Design Patterns Tame Non-Deterministic AI

AI agents are inherently non-deterministic — every call can produce different results. We use classical GoF design patterns not as convention, but as structural guardrails that impose determinism on the system surrounding the AI.

Design Patterns GoF Determinism TypeScript

The Problem We Found

When we started building HubAI’s code generation pipeline, we faced a fundamental tension: the AI at the center of our system is non-deterministic, but the output must be deterministic.

Every call to an LLM can produce different results. The same prompt, the same model, the same temperature — and you get different phrasing, different variable names, different structural decisions. For a chatbot, this variability is acceptable. For enterprise code generation, it is not.

As the industry shifted from agent prototypes to production deployments this year, this tension became universal. We could not make the AI deterministic. No amount of prompt engineering guarantees identical output across runs. But we could make everything around the AI deterministic — the pipeline, the orchestration, the generation, the validation.

That is where classical design patterns became essential. Not as academic exercises or resume padding, but as structural guardrails that impose order on a fundamentally chaotic component.

The Patterns We Use and Why

Template Method: One Pipeline, Many Generators

The Template Method pattern defines the skeleton of an algorithm in a base class, letting subclasses override specific steps without changing the overall structure.

In HubAI, AbstractGenerator defines the generation flow:

abstract class AbstractGenerator {
  async generate(schema: ValidatedSchema): Promise<GeneratedFile[]> {
    const template = await this.loadTemplate();
    const variables = this.mapVariables(schema);
    const rendered = this.render(template, variables);
    const formatted = this.format(rendered);
    return this.write(formatted);
  }

  abstract loadTemplate(): Promise<string>;
  abstract mapVariables(schema: ValidatedSchema): TemplateVars;
}

Every generator — ApolloGenerator, ExpressGenerator, ViteGenerator, ServerlessGenerator — follows this exact flow. They differ in which template to load and how to map schema fields to template variables. But the pipeline itself — load, map, render, format, write — is invariant.

This guarantees that adding a new backend type (say, a Fastify generator) cannot break the generation pipeline. The new generator must implement the abstract methods, and the Template Method enforces the correct execution order.

Factory: Consistent Agent Creation

Each agent in HubAI requires specific configuration — model, system prompt, tools, token budget, context strategy. Getting any of these wrong produces subtle, hard-to-debug failures.

The Factory pattern centralizes agent creation:

function getOrchestratorAgent(): OrchestratorAgent {
  return OrchestratorAgent.getInstance({
    model: config.orchestratorModel,
    tools: orchestratorTools,
    tokenBudget: 8192,
    contextStrategy: 'progressive',
  });
}

Agent factories are singletons — the same agent instance is reused across the orchestration lifecycle. This ensures consistent state and prevents configuration drift when the same agent type is needed in multiple places.

Strategy: Pluggable Generators

Different project configurations require different generation algorithms. An Apollo GraphQL service has different resolver patterns than an Express REST API. A serverless deployment has different file structures than a monorepo.

The Strategy pattern makes these algorithms interchangeable:

interface GeneratorStrategy {
  generate(schema: ValidatedSchema): Promise<GeneratedFile[]>;
}

class GenerationContext {
  constructor(private strategy: GeneratorStrategy) {}

  async execute(schema: ValidatedSchema) {
    return this.strategy.generate(schema);
  }

  setStrategy(strategy: GeneratorStrategy) {
    this.strategy = strategy;
  }
}

The Strategy is selected at runtime based on project configuration. Same interface, different algorithm. New backend types can be added by implementing GeneratorStrategy without modifying any existing code.

Observer: Event Coordination Without Coupling

Multiple systems need to react to agent events — the UI needs progress updates, logging needs event traces, metrics need performance data. Tight coupling between agents and these systems would create a maintenance nightmare.

The Observer pattern decouples event producers from consumers:

interface AgentObserver {
  onStageChange?(stage: string, context: unknown): void;
  onToolExecution?(tool: string, result: unknown): void;
  onProgress?(phase: string, percent: number): void;
}

The MessageBus acts as the subject. Agents emit events. Observers subscribe to the events they care about. Adding a new observer (say, a Slack notification system) requires zero changes to the agent code.

Builder: Incremental Schema Construction

Frontend schemas are complex objects with many optional parts — page layouts, component configurations, form rules, data bindings, navigation structures. Constructing these in a single step is error-prone.

The Builder pattern constructs them incrementally:

class FrontendBuilder {
  private schema: Partial<FrontendSchema> = {};

  withPageLayout(layout: PageLayout): this {
    this.schema.layout = layout;
    return this;
  }

  withComponents(components: ComponentTree): this {
    this.schema.components = components;
    return this;
  }

  withDataBindings(bindings: DataBinding[]): this {
    this.schema.bindings = bindings;
    return this;
  }

  build(): FrontendSchema {
    this.validate();
    return this.schema as FrontendSchema;
  }
}

Each step validates its input. The final build() call validates the complete schema. If any step fails, the error is localized and actionable — not a cryptic failure 500 lines into generated code.

The 150-Line Rule

Every source file in HubAI must contain at most 150 lines of code. This is not a guideline — it is an enforced constraint.

The rule exists because design patterns without discipline degenerate into monolithic “God classes.” A Strategy pattern in a 2,000-line file is not a pattern — it is a decoration on a monolith.

The splitting strategies are concrete:

  • Extract interfaces to *.interface.ts
  • Move helpers to *.utils.ts
  • Split a class into a base and implementation files
  • One pattern per file, one responsibility per file

This forces every file to have a single, clear purpose — which makes the patterns actually work as intended.

The Key Insight

Design patterns are not about elegance. In an AI system, they are about survival.

The AI at the center of HubAI is a black box that can produce different output on every call. The patterns surrounding it ensure that:

  • The pipeline is always the same (Template Method)
  • Agents are always configured correctly (Factory)
  • Algorithms can be swapped without side effects (Strategy)
  • Events flow without coupling (Observer)
  • Complex objects are built safely (Builder)

The AI is non-deterministic. The pipeline is not. That is the difference between a tool that works in demos and a system that works in production.

Patterns impose structural determinism on the system surrounding the AI. The AI does what AI does — reasons, improvises, varies. The patterns ensure that variation is contained, channeled, and ultimately irrelevant to the deterministic output.