Facades

Overview

Facades provide a static interface to classes available in the application's service container. They act as "static proxies" to underlying classes in the container, offering a concise, expressive syntax while maintaining testability and flexibility.

In Orchestr (like Laravel), facades serve as a convenient way to access services without manually resolving them from the container every time. Instead of repeatedly calling app.make('router'), you can simply use Route.get().

Concise Syntax

Clean, readable code without verbose dependency resolution

Service Container Integration

Automatic resolution of dependencies

Testability

Easy to mock and swap implementations

IDE Support

Full TypeScript type hints and autocomplete

Lazy Loading

Services are resolved only when actually used

How Facades Work

Behind every facade is a service registered in the application's container. When you call a static method on a facade, the following happens:

  1. Accessor Resolution: The facade identifies its accessor name (e.g., 'router' or 'db')
  2. Container Lookup: The facade resolves the service from the container using app.make(accessor)
  3. Instance Caching: The resolved instance is cached for subsequent calls
  4. Method Proxying: The static method call is forwarded to the actual instance method
// When you write this:
Route.get('/users', handler);

// This happens behind the scenes:
const router = app.make('router');  // Resolve Router from container
router.get('/users', handler);      // Call the actual method

The Facade Base Class

All facades extend the Facade base class, which provides the core functionality:

// Simplified implementation from /Users/samstreet/Projects/orchestr/src/Support/Facade.ts
export abstract class Facade {
  private static app: Application;
  private static resolvedInstances: Map<string, any> = new Map();

  // Set the application instance (done during bootstrap)
  static setFacadeApplication(app: Application): void {
    Facade.app = app;
  }

  // Override this in child classes to specify the service key
  protected static getFacadeAccessor(): string {
    throw new Error('Facade does not implement getFacadeAccessor method.');
  }

  // Resolve the actual service from the container
  protected static resolveFacadeInstance(name: string): any {
    if (Facade.resolvedInstances.has(name)) {
      return Facade.resolvedInstances.get(name);
    }

    if (Facade.app) {
      const instance = Facade.app.make(name);
      Facade.resolvedInstances.set(name, instance);
      return instance;
    }

    throw new Error('A facade root has not been set.');
  }

  // Get the resolved instance
  protected static getFacadeRoot(): any {
    return Facade.resolveFacadeInstance(this.getFacadeAccessor());
  }
}

Built-in Facades

Orchestr provides two core facades out of the box.

Route Facade

The Route facade provides static access to the Router service for defining application routes.

Service Accessor: 'router'
Underlying Class: Router (from /Users/samstreet/Projects/orchestr/src/Routing/Router.ts)
Registration: Automatically registered by RouteServiceProvider

Available Methods

// HTTP verb methods
Route.get(uri: string, action: RouteAction): Route
Route.post(uri: string, action: RouteAction): Route
Route.put(uri: string, action: RouteAction): Route
Route.patch(uri: string, action: RouteAction): Route
Route.delete(uri: string, action: RouteAction): Route
Route.any(uri: string, action: RouteAction): Route
Route.match(methods: HttpMethod[], uri: string, action: RouteAction): Route

// Route grouping
Route.group(attributes: RouteGroupAttributes, callback: () => void): void

Usage Examples

import { Route } from '@orchestr-sh/orchestr';

// Simple GET route
Route.get('/users', async (req, res) => {
  return res.json({ users: [] });
});

// Route with parameters
Route.get('/users/:id', async (req, res) => {
  const id = req.routeParam('id');
  return res.json({ user: { id } });
});

// POST route
Route.post('/users', async (req, res) => {
  const data = req.only(['name', 'email']);
  return res.status(201).json({ user: data });
});

// Route groups with shared attributes
Route.group({ prefix: '/api/v1', middleware: ['auth'] }, () => {
  Route.get('/profile', profileHandler);
  Route.post('/logout', logoutHandler);
});

// Multiple HTTP methods
Route.match(['GET', 'POST'], '/contact', contactHandler);

DB Facade

The DB facade provides static access to the DatabaseManager service for database operations.

Service Accessor: 'db'
Underlying Class: DatabaseManager (from /Users/samstreet/Projects/orchestr/src/Database/DatabaseManager.ts)
Registration: Automatically registered by DatabaseServiceProvider

Available Methods

// Connection management
DB.connection(name?: string): Connection

// Query builder (via default connection)
DB.table(tableName: string): Builder

// Raw queries (via default connection)
DB.select(sql: string, bindings?: any[]): Promise<any[]>
DB.insert(sql: string, bindings?: any[]): Promise<any>
DB.update(sql: string, bindings?: any[]): Promise<number>
DB.delete(sql: string, bindings?: any[]): Promise<number>
DB.query(sql: string, bindings?: any[]): Promise<any>

// Transactions (via default connection)
DB.beginTransaction(): Promise<void>
DB.commit(): Promise<void>
DB.rollback(): Promise<void>

// Connection lifecycle
DB.disconnect(name?: string): Promise<void>
DB.disconnectAll(): Promise<void>

Usage Examples

import { DB } from '@orchestr-sh/orchestr';

// Query builder
const users = await DB.table('users')
  .where('active', true)
  .orderBy('created_at', 'desc')
  .get();

// Insert data
await DB.table('users').insert({
  name: 'John Doe',
  email: 'john@example.com',
  created_at: new Date()
});

// Update records
await DB.table('users')
  .where('id', userId)
  .update({ last_login: new Date() });

// Delete records
await DB.table('users')
  .where('deleted_at', '<', thirtyDaysAgo)
  .delete();

// Raw queries
const results = await DB.select(
  'SELECT * FROM users WHERE email = ?',
  ['john@example.com']
);

// Transactions
await DB.beginTransaction();
try {
  await DB.table('accounts').where('id', 1).decrement('balance', 100);
  await DB.table('accounts').where('id', 2).increment('balance', 100);
  await DB.commit();
} catch (error) {
  await DB.rollback();
  throw error;
}

// Use a specific connection
const analyticsData = await DB.connection('analytics')
  .table('events')
  .count();

Creating Custom Facades

You can create your own facades to provide static access to your services.

Step 1: Create the Service

First, create the service you want to facade:

// services/CacheService.ts
export class CacheService {
  private cache: Map<string, any> = new Map();

  get(key: string): any {
    return this.cache.get(key);
  }

  set(key: string, value: any): void {
    this.cache.set(key, value);
  }

  has(key: string): boolean {
    return this.cache.has(key);
  }

  forget(key: string): void {
    this.cache.delete(key);
  }

  flush(): void {
    this.cache.clear();
  }
}

Step 2: Register the Service

Register your service in a service provider:

// providers/CacheServiceProvider.ts
import { ServiceProvider } from '@orchestr-sh/orchestr';
import { CacheService } from '../services/CacheService';

export class CacheServiceProvider extends ServiceProvider {
  register(): void {
    // Register as singleton so the same instance is used
    this.app.singleton('cache', () => new CacheService());
  }
}

Step 3: Create the Facade

Create a facade class that extends Facade:

// facades/Cache.ts
import { Facade } from '@orchestr-sh/orchestr';
import { CacheService } from '../services/CacheService';

class CacheFacade extends Facade {
  protected static getFacadeAccessor(): string {
    return 'cache'; // Must match the container binding
  }

  // Type hints for IDE support
  static get: CacheService['get'];
  static set: CacheService['set'];
  static has: CacheService['has'];
  static forget: CacheService['forget'];
  static flush: CacheService['flush'];
}

// Create proxy to enable static method calls
export const Cache = new Proxy(CacheFacade, {
  get(target, prop) {
    // Try to get from the facade root (the actual service)
    try {
      const root = (target as any).getFacadeRoot();
      if (root && prop in root) {
        const value = root[prop];
        if (typeof value === 'function') {
          return (...args: any[]) => value.apply(root, args);
        }
        return value;
      }
    } catch (error) {
      // Facade root not available yet
    }

    // Fall back to static properties/methods
    if (typeof prop === 'string' && prop in target) {
      const value = (target as any)[prop];
      if (typeof value === 'function') {
        return value.bind(target);
      }
      return value;
    }

    return undefined;
  }
}) as unknown as typeof CacheFacade & CacheService;

Step 4: Bootstrap

Register your provider and set up facades:

import 'reflect-metadata';
import { Application, Facade } from '@orchestr-sh/orchestr';
import { CacheServiceProvider } from './providers/CacheServiceProvider';
import { Cache } from './facades/Cache';

// Create and bootstrap application
const app = new Application(__dirname);

// Set facade application
Facade.setFacadeApplication(app);

// Register providers
app.register(CacheServiceProvider);
await app.boot();

// Now use your facade!
Cache.set('user:1', { name: 'John Doe' });
const user = Cache.get('user:1');
console.log(user); // { name: 'John Doe' }

Facade Accessor Implementation

The facade accessor pattern is the heart of how facades work. Here's a detailed look at the implementation:

Basic Accessor Pattern

class MyServiceFacade extends Facade {
  // This tells the facade which container binding to resolve
  protected static getFacadeAccessor(): string {
    return 'my-service';
  }
}

Proxy Pattern for Method Forwarding

The proxy pattern enables calling instance methods statically:

export const MyService = new Proxy(MyServiceFacade, {
  get(target, prop) {
    // 1. Try to resolve the actual service instance
    const root = (target as any).getFacadeRoot();

    // 2. If the property is a method on the service, proxy it
    if (root && typeof root[prop] === 'function') {
      return (...args: any[]) => root[prop](...args);
    }

    // 3. If it's a property, return it directly
    if (root && prop in root) {
      return root[prop];
    }

    // 4. Fall back to static facade methods
    if (typeof prop === 'string' && prop in target) {
      const value = (target as any)[prop];
      if (typeof value === 'function') {
        return value.bind(target);
      }
      return value;
    }
  }
});

Type Safety with TypeScript

Add type hints to get full IDE support:

class MyServiceFacade extends Facade {
  protected static getFacadeAccessor(): string {
    return 'my-service';
  }

  // Declare method signatures for autocomplete
  static doSomething: MyService['doSomething'];
  static calculate: MyService['calculate'];
  static property: MyService['property'];
}

// Type the proxy export
export const MyService = new Proxy(MyServiceFacade, {
  // ... proxy implementation
}) as unknown as typeof MyServiceFacade & MyService;

Testing with Facades

Facades are designed to be testable despite their static interface.

Mock a Facade

You can swap the underlying implementation in tests:

import { describe, it, beforeEach, afterEach } from 'node:test';
import { Application, Facade } from '@orchestr-sh/orchestr';
import { Cache } from '../facades/Cache';

describe('User Service', () => {
  let app: Application;
  let mockCache: any;

  beforeEach(async () => {
    // Create fresh application
    app = new Application(__dirname);
    Facade.setFacadeApplication(app);

    // Create mock cache
    mockCache = {
      get: (key: string) => 'mocked-value',
      set: (key: string, value: any) => {},
      has: (key: string) => true,
    };

    // Bind mock to container
    app.instance('cache', mockCache);

    await app.boot();
  });

  afterEach(() => {
    // Clear resolved instances
    Facade.clearResolvedInstances();
  });

  it('should use mocked cache', () => {
    const value = Cache.get('test-key');
    expect(value).toBe('mocked-value');
  });
});

Clear Resolved Instances

Between tests, clear the facade cache:

import { Facade } from '@orchestr-sh/orchestr';

afterEach(() => {
  // Clear all cached facade instances
  Facade.clearResolvedInstances();

  // Or clear a specific facade
  Facade.clearResolvedInstance('cache');
});

Spy on Facade Methods

Since facades proxy to real instances, you can spy on the underlying service:

import { describe, it, mock } from 'node:test';
import { DB } from '@orchestr-sh/orchestr';

it('should call database insert', async () => {
  // Get the underlying database manager
  const dbManager = (DB as any).getFacadeRoot();
  const connection = dbManager.connection();

  // Spy on the table method
  const tableSpy = mock.method(connection, 'table');

  // Perform action that uses DB
  await DB.table('users').insert({ name: 'Test' });

  // Verify the spy was called
  expect(tableSpy.mock.calls.length).toBe(1);
  expect(tableSpy.mock.calls[0].arguments[0]).toBe('users');
});

Facade vs Dependency Injection

Both facades and dependency injection (DI) have their place. Understanding when to use each is important.

Use Facades When:

  • Writing application code (controllers, route handlers, scripts)
  • You need concise syntax for frequently used services
  • The dependency is obvious from context (routes always use Router)
  • Rapid development is the priority
  • Static helpers make the code more readable
// Good use of facades - application code
Route.get('/users', async (req, res) => {
  const users = await DB.table('users')
    .where('active', true)
    .get();

  return res.json({ users });
});

Use Dependency Injection When:

  • Writing reusable packages or libraries
  • Complex business logic with multiple dependencies
  • Testing is critical and you need explicit dependencies
  • Dependencies aren't obvious from the class context
  • You want loose coupling between classes
// Good use of DI - service layer
import { Injectable } from '@orchestr-sh/orchestr';

@Injectable()
export class UserService {
  constructor(
    private emailService: EmailService,
    private notificationService: NotificationService,
    private logger: Logger
  ) {}

  async createUser(data: CreateUserDto) {
    // Complex business logic with explicit dependencies
    const user = await this.saveUser(data);
    await this.emailService.sendWelcome(user);
    await this.notificationService.notify(user);
    this.logger.info('User created', { userId: user.id });
    return user;
  }
}

Comparison Example

Here's the same functionality implemented both ways:

// Using Facades (concise, good for simple cases)
Route.post('/users', async (req, res) => {
  const data = req.only(['name', 'email']);

  await DB.beginTransaction();
  try {
    const user = await DB.table('users').insert(data);
    await DB.table('audit_log').insert({
      action: 'user_created',
      user_id: user.id
    });
    await DB.commit();
    return res.json({ user });
  } catch (error) {
    await DB.rollback();
    throw error;
  }
});

// Using Dependency Injection (testable, good for complex logic)
@Injectable()
export class UserController extends Controller {
  constructor(
    private userService: UserService,
    private auditLogger: AuditLogger
  ) {
    super();
  }

  async store(req: Request, res: Response) {
    const data = req.only(['name', 'email']);

    const user = await this.userService.createWithAudit(data);

    return res.json({ user });
  }
}

// Service with injected dependencies
@Injectable()
export class UserService {
  constructor(
    private db: DatabaseManager,
    private auditLogger: AuditLogger
  ) {}

  async createWithAudit(data: CreateUserDto) {
    await this.db.beginTransaction();
    try {
      const user = await this.db.table('users').insert(data);
      await this.auditLogger.log('user_created', user.id);
      await this.db.commit();
      return user;
    } catch (error) {
      await this.db.rollback();
      throw error;
    }
  }
}

Hybrid Approach

You can combine both patterns effectively:

@Injectable()
export class OrderService {
  // Inject complex dependencies
  constructor(
    private paymentGateway: PaymentGateway,
    private inventoryService: InventoryService
  ) {}

  async createOrder(data: CreateOrderDto) {
    // Use DI for injected services
    const hasStock = await this.inventoryService.checkStock(data.items);
    if (!hasStock) {
      throw new Error('Insufficient stock');
    }

    // Use facades for framework services (concise and appropriate)
    await DB.beginTransaction();
    try {
      const order = await DB.table('orders').insert(data);
      await this.paymentGateway.charge(order);
      await this.inventoryService.reserve(data.items);
      await DB.commit();
      return order;
    } catch (error) {
      await DB.rollback();
      throw error;
    }
  }
}

Best Practices

1. Use Facades for Framework Services

Facades work best for core framework services like routing and database:

// Good
Route.get('/users', handler);
const users = await DB.table('users').get();

// Overkill
constructor(private router: Router, private db: DatabaseManager) {}

2. Don't Create Too Many Facades

Only create facades for frequently used services. Not every service needs a facade.

// Good - frequently used
Cache.get('key');
Log.info('message');

// Questionable - rarely used
PDF.generate(data);
Markdown.parse(text);

3. Always Register Services as Singletons

Facades assume the underlying service is a singleton:

// Good
app.singleton('cache', () => new CacheService());

// Bad - will create new instances on each call
app.bind('cache', () => new CacheService());

4. Provide Type Hints

Always add type hints to your facade for IDE support:

class MyFacade extends Facade {
  protected static getFacadeAccessor(): string {
    return 'my-service';
  }

  // Add type hints
  static method1: MyService['method1'];
  static method2: MyService['method2'];
}

5. Clear Instances in Tests

Always clear facade instances between tests to avoid state leakage:

afterEach(() => {
  Facade.clearResolvedInstances();
});

6. Document Your Facades

Add JSDoc comments to help other developers:

/**
 * Cache Facade
 *
 * Provides static access to the caching service.
 *
 * @example
 * ```typescript
 * Cache.set('user:1', userData);
 * const user = Cache.get('user:1');
 * ```
 */
export const Cache = new Proxy(CacheFacade, {
  // ...
});

Important Setup

Always set the facade application before using any facades, otherwise they will throw errors.

7. Bootstrap Properly

Always set the facade application before using facades:

import 'reflect-metadata';
import { Application, Facade } from '@orchestr-sh/orchestr';

const app = new Application(__dirname);

// IMPORTANT: Set this before using any facades
Facade.setFacadeApplication(app);

// Register providers
app.register(RouteServiceProvider);
await app.boot();

// Now facades will work
Route.get('/test', handler);

8. Consider the Trade-offs

Facades trade explicit dependencies for convenience. Use them wisely:

Pros:

  • Concise, readable code
  • Less boilerplate
  • Familiar to Laravel developers
  • Easy to use in simple scenarios

Cons:

  • Hidden dependencies (not visible in constructor)
  • Harder to test in isolation without mocking
  • Can make code harder to understand for new developers
  • Static nature limits some advanced patterns

Summary

Facades provide an elegant, expressive way to access services from the container through a static interface. They're perfect for application code, route definitions, and quick database queries. However, for complex business logic, reusable packages, or when testing is paramount, traditional dependency injection may be more appropriate.

Key takeaways:

  1. Facades are static proxies to container services
  2. They resolve services lazily and cache instances
  3. Built-in facades include Route and DB
  4. Create custom facades by extending Facade and using proxies
  5. Facades are testable through container mocking
  6. Use facades for framework services, DI for business logic
  7. Always bootstrap facades properly with Facade.setFacadeApplication(app)

Additional Resources