Application Controller

Posted by Dustin Boston .

The Application Controller pattern centralizes navigation logic in one place. It acts as an intermediary between the user interface and the domain logic. When a user takes action, the UI notifies the Application Controller. Then, it checks a set of rules based on the app’s current state. This helps decide which business logic (Domain Command) to run and which view to show next. This pattern is great for apps with complex screen flows, such as wizards.

Source Code Listing

code.ts

import {type Serve} from "bun";

// =================================================================
// 1. DOMAIN LAYER
// Simulates the core business entities and logic.
// =================================================================

export enum StepStatus {
  InProgress = "IN_PROGRESS",
  Completed = "COMPLETED",
  NotStarted = "NOT_STARTED",
}

export class Step {
  id: string;
  status: StepStatus;

  constructor(id: string, initialStatus: StepStatus = StepStatus.NotStarted) {
    this.id = id;
    this.status = initialStatus;
  }

  /**
   * In-memory repository to persist state.
   */
  private static repository = new Map<string, Step>();

  /**
   * Logic to find and return a Step instance by id
   * @param id
   * @returns
   */
  static find(id: string): Step | undefined {
    return this.repository.get(id);
  }

  /**
   * Logic to save a Step instance
   * @param step
   */
  static save(step: Step): void {
    this.repository.set(step.id, step);
  }

  static initializeWizard(): void {
    Step.save(new Step("step1", StepStatus.InProgress));
    Step.save(new Step("step2"));
    Step.save(new Step("step3"));
  }
}

// =================================================================
// 2. APPLICATION LAYER
// Contains the Application Controller and Command patterns.
// =================================================================

export type RequestParams = Record<string, any>;

export interface DomainCommand {
  run(params: RequestParams): void;
}

/**
 * Maps a command to its action and the *next* view.
 */
class CommandViewResponse {
  constructor(
    public readonly commandConstructor: new () => DomainCommand,
    public readonly nextView: string,
  ) {}

  getDomainCommand(): DomainCommand {
    return new this.commandConstructor();
  }
}

/**
 * Completes step 1 and moves to step 2.
 * Only worries about the logic for its own step.
 * The Application Controller handles what comes next.
 */
class ProcessStep1Command implements DomainCommand {
  run(_params: RequestParams): void {
    console.log("   ACTION: Processing Step 1 submission.");
    const step1 = Step.find("step1")!;
    const step2 = Step.find("step2")!;
    step1.status = StepStatus.Completed;
    step2.status = StepStatus.InProgress;
  }
}

/**
 * Completes step 2 and moves to step 3,
 * or goes back to step 1 if "prev" is specified.
 * This command is more complex as it handles both directions.
 */
class ProcessStep2Command implements DomainCommand {
  run(params: RequestParams): void {
    console.log(`   ACTION: Processing Step 2 submission (Direction: ${params.direction}).`);
    const step1 = Step.find("step1")!;
    const step2 = Step.find("step2")!;
    const step3 = Step.find("step3")!;

    if (params.direction === "next") {
      step2.status = StepStatus.Completed;
      step3.status = StepStatus.InProgress;
    } else {
      // "prev"
      step2.status = StepStatus.NotStarted;
      step1.status = StepStatus.InProgress;
    }
  }
}

/**
 * The Application Controller.
 * It maps a command name (e.g., "submit-step1") directly to a command and a subsequent view.
 */
export class WizardApplicationController {
  private rules = new Map<string, CommandViewResponse>();

  constructor() {
    this.loadRules();
  }

  private loadRules(): void {
    // Command from Step 1 leads to Step 2 View
    this.rules.set("submit-step1", new CommandViewResponse(ProcessStep1Command, "step2View"));

    // Command from Step 2 ("next") leads to Step 3 View
    this.rules.set("submit-step2-next", new CommandViewResponse(ProcessStep2Command, "step3View"));

    // Command from Step 2 ("prev") leads to Step 1 View
    this.rules.set("submit-step2-prev", new CommandViewResponse(ProcessStep2Command, "step1View"));
  }

  private getResponse(commandString: string): CommandViewResponse {
    const response = this.rules.get(commandString);
    if (!response) {
      throw new Error(`No rule defined for command: ${commandString}`);
    }
    return response;
  }

  getDomainCommand(commandString: string): DomainCommand {
    return this.getResponse(commandString).getDomainCommand();
  }

  getView(commandString: string): string {
    return this.getResponse(commandString).nextView;
  }
}

// =================================================================
// 3. PRESENTATION LAYER
// =================================================================

// --- Setup ---
// Initialize the domain state
Step.initializeWizard();

// Instantiate the controller ONCE. This is the singleton.
const appController = new WizardApplicationController();

console.log("✅ Server started. Wizard is initialized.");
console.log("Try visiting:");
console.log("  - http://localhost:3000/submit-step1");
console.log("  - http://localhost:3000/submit-step2-next");
console.log("  - http://localhost:3000/submit-step2-prev");

export default {
  port: 3000,
  fetch(req) {
    const url = new URL(req.url);
    const command = url.pathname.substring(1); // Get command from path, e.g., "submit-step1"

    console.log(`\n➡️  Handling request for command: "${command}"`);

    try {
      const commandString = command;
      const params: RequestParams = {};

      // A simple way to distinguish between prev/next for step 2
      if (command.includes("next")) params.direction = "next";
      if (command.includes("prev")) params.direction = "prev";

      // 1. Get the domain command
      const domainCommand = appController.getDomainCommand(commandString);

      // 2. Run it
      domainCommand.run(params);

      // 3. Get the next view
      const viewPage = appController.getView(commandString);

      // 4. Respond
      const responseBody = `Successfully processed '${commandString}'.\nNext view: ${viewPage}.`;
      console.log(`   VIEW: Responding with next view: ${viewPage}`);
      return new Response(responseBody);
    } catch (error) {
      console.error("   ERROR:", error);
      return new Response("Invalid command or server error.", {status: 400});
    }
  },
} satisfies Serve;