Skip to content

Architectural Patterns

IGNIS promotes separation of concerns, dependency injection, and modularity for scalable, maintainable applications.

1. Layered Architecture

Each layer has a single responsibility. IGNIS supports two architectural approaches:

LayerResponsibilityExample
ControllersHandle HTTP/gRPC - parse requests, validate, format responsesConfigurationController (REST), GreeterController (gRPC)
ServicesBusiness logic - orchestrate operationsAuthenticationService (auth logic)
RepositoriesData access - CRUD operationsConfigurationRepository (extends DefaultRelationalRepository)
DataSourcesDatabase connectionsPostgresDataSource (connects to PostgreSQL)
ModelsData structure - Drizzle schemas + Entity classesConfiguration, User models

Key Principle - Two Approaches:

Simple CRUD (no business logic):
┌────────────┐
│ Controller │──────────────┐
└────────────┘              │

                    ┌──────────────┐
                    │  Repository  │
                    └──────────────┘


                        Database

Complex Logic (validation, orchestration):
┌────────────┐
│ Controller │────┐
└────────────┘    │

            ┌─────────┐
            │ Service │
            └─────────┘


          ┌──────────────┐
          │  Repository  │
          └──────────────┘


              Database

When to use each:

  • Controller → Repository - Simple CRUD (list, get by ID, create, update, delete)
  • Controller → Service → Repository - Business logic, validation, orchestrating multiple repositories

2. Dependency Injection (DI)

Classes declare dependencies in their constructor - the framework automatically provides them at runtime.

Benefits:

  • Loosely coupled code
  • Easy to test (mock dependencies)
  • Easy to swap implementations

Example (REST controller):

typescript
@controller({ path: BASE_PATH })
export class ConfigurationController extends _Controller {
  constructor(
    // The @inject decorator tells the container to provide
    // an instance of ConfigurationRepository here.
    @inject({
      key: BindingKeys.build({
        namespace: BindingNamespaces.REPOSITORY,
        key: ConfigurationRepository.name,
      }),
    })
    repository: ConfigurationRepository,
  ) {
    super(repository);
  }
}

Controller Transports:

The @controller decorator supports a transport field to distinguish between REST and gRPC controllers:

typescript
// REST controller (default - transport can be omitted)
@controller({ path: '/users' })
export class UserController extends BaseRestController { ... }

// gRPC controller (transport is required)
@controller({ path: '/greeter', transport: 'grpc', service: GreeterService })
export class GreeterController extends BaseGrpcController { ... }

REST controllers extend BaseRestController, while gRPC controllers extend BaseGrpcController. The application must enable the appropriate transport(s) in its configuration.

3. Component-Based Modularity

Components bundle a group of related, reusable, and pluggable features into self-contained modules. A single component can encapsulate multiple providers, services, controllers, and repositories, essentially functioning as a mini-application that can be easily "plugged in" to any IGNIS project.

Built-in Components:

  • AuthenticateComponent - JWT authentication
  • ApiReferenceComponent - OpenAPI documentation
  • HealthCheckComponent - Health check endpoint
  • RequestTrackerComponent - Request logging

Example:

typescript
// src/application.ts

export class Application extends BaseApplication {
  // ...
  preConfigure(): ValueOrPromise<void> {
    // ...
    // Registering components plugs their functionality into the application.
    this.component(HealthCheckComponent);
    this.component(ApiReferenceComponent);
    // ...
  }
}

This architecture keeps the main Application class clean and focused on high-level assembly, while the details of each feature are neatly encapsulated within their respective components.

4. Custom Components

You can encapsulate your own logic or third-party integrations (like Socket.IO, Redis, specific Cron jobs) into reusable Components.

Structure of a Component:

  1. Extend BaseComponent.
  2. Define default bindings (optional configuration/options).
  3. Implement binding() to register services, providers, or attach logic to the application.

Example (SocketIOComponent):

typescript
import { BaseApplication, BaseComponent, inject, CoreBindings, Binding } from '@venizia/ignis';

export class MySocketComponent extends BaseComponent {
  constructor(
    @inject({ key: CoreBindings.APPLICATION_INSTANCE }) private application: BaseApplication,
  ) {
    super({
      scope: MySocketComponent.name,
      // Automatically register bindings when component is loaded
      initDefault: { enable: true, container: application },
      bindings: {
        // Define default configuration binding
        'my.socket.options': Binding.bind({ key: 'my.socket.options' }).toValue({ port: 8080 }),
      },
    });
  }

  // The binding method is called when the application configures components (registerComponents)
  override binding(): void {
    const options = this.application.get({ key: 'my.socket.options' });
    
    this.logger.info('Initializing Socket.IO with options: %j', options);
    
    // Perform setup logic, register other services, etc.
    // this.application.bind(...).toValue(...);
  }
}

5. Application Lifecycle Hooks

IGNIS applications follow a predictable startup sequence with hooks for customization:

┌─────────────────────────────────────────────────────────────┐
│                     initialize()                            │
├─────────────────────────────────────────────────────────────┤
│  1. printStartUpInfo()     - Log startup configuration      │
│  2. validateEnvs()         - Validate APP_ENV_* variables   │
│  3. registerDefaultMiddlewares() - Error handlers, favicon  │
│  4. staticConfigure()      - Configure static file serving  │
│                                                             │
│  ┌─────────────────────────────────────────────────────┐    │
│  │ 5. preConfigure()  ← YOUR CODE HERE                 │    │
│  │    - Register DataSources                           │    │
│  │    - Register Repositories                          │    │
│  │    - Register Services                              │    │
│  │    - Register Controllers                           │    │
│  │    - Register Components                            │    │
│  └─────────────────────────────────────────────────────┘    │
│                                                             │
│  6. hydrateSecrets()       - Resolve secrets into env        │
│  7. registerDataSources()  - Initialize DB connections      │
│  8. registerComponents()   - Configure all components       │
│  9. wireSecretRotatables() - Attach rotation listeners      │
│ 10. registerControllers()  - Mount routes to router         │
│                                                             │
│  ┌─────────────────────────────────────────────────────┐    │
│  │ 11. postConfigure()  ← YOUR CODE HERE               │    │
│  │    - Seed data                                      │    │
│  │    - Start background jobs                          │    │
│  │    - Custom initialization                          │    │
│  └─────────────────────────────────────────────────────┘    │
└─────────────────────────────────────────────────────────────┘

hydrateSecrets() runs after preConfigure() so a secrets provider registered there is available, and before registerDataSources() so datasources read already-resolved values. wireSecretRotatables() runs after components, because a component may contribute the datasource a rotation lease points at.

Lifecycle Methods:

MethodWhenPurpose
staticConfigure()Before DI registrationConfigure static file serving
preConfigure()Before auto-registrationRegister all bindings (datasources, repos, services, controllers, components)
postConfigure()After everything is registeredSeed data, start jobs, custom logic

Example:

typescript
export class Application extends BaseApplication {
  // Called before automatic registration
  async preConfigure(): Promise<void> {
    // DataSources (order matters - first)
    this.dataSource(PostgresDataSource);

    // Repositories
    this.repository(UserRepository);
    this.repository(OrderRepository);

    // Services
    this.service(AuthService);
    this.service(EmailService);

    // Controllers
    this.controller(UserController);
    this.controller(OrderController);

    // Components
    this.component(AuthenticateComponent);
    this.component(ApiReferenceComponent);
  }

  // Called after all registrations complete
  async postConfigure(): Promise<void> {
    // Access registered services
    const userRepository = this.get<UserRepository>({
      key: BindingKeys.build({
        namespace: BindingNamespaces.REPOSITORY,
        key: UserRepository.name,
      }),
    });

    // Seed initial data (findOne returns the record or null)
    const adminExists = await userRepository.findOne({
      filter: { where: { role: 'admin' } },
    });
    if (!adminExists) {
      await userRepository.create({ data: { name: 'Admin', role: 'admin' } });
    }
  }

  // Configure static file serving
  staticConfigure(): void {
    this.static({ restPath: '/public/*', folderPath: './public' });
  }
}

WARNING

Do not register new datasources, components, or controllers in postConfigure(). They will not be automatically initialized. Use preConfigure() for all registrations.

6. Registration Surface & Capability Interfaces

BaseApplication implements the full resource-registration surface directly - service(), repository(), dataSource(), controller(), component(), and booter(). Extend BaseApplication and call these methods straight from your lifecycle hooks; there is nothing to compose.

How registration works:

typescript
// BaseApplication implements service() directly (no mixin composition):
service<Base extends IService, Args extends AnyObject = any>(
  ctor: TClass<Base>,
  opts?: TMixinOpts<Args>,
): Binding<Base> {
  return this.bind<Base>({
    key: BindingKeys.build(
      opts?.binding ?? { namespace: BindingNamespaces.SERVICE, key: ctor.name },
    ),
  }).toClass(ctor);
}

Every registration method takes the same optional second argument - opts.binding overrides the derived { namespace, key } when you need to register two classes under one contract.

Capability interfaces:

Each registration capability is declared as a TypeScript interface that IRestApplication (and therefore BaseApplication) implements. Reference these when you type your own application contracts:

InterfaceMethodsPurpose
IServiceMixinservice()Register service classes
IRepositoryMixindataSource(), repository()Register data layer
IComponentMixincomponent(), registerComponents()Register modular components
IControllerMixincontroller(), registerControllers()Register controllers and mount routes
IServerConfigMixinstaticConfigure(), preConfigure(), postConfigure(), getApplicationVersion()Lifecycle hooks
IStaticServeMixinstatic()Serve static files

NOTE

Earlier releases also exported ServiceMixin, RepositoryMixin, and ComponentMixin as class-mixin functions you composed onto AbstractApplication. They duplicated BaseApplication's own methods verbatim, drifted out of sync, and had no known consumers, so they were removed. The IServiceMixin / IRepositoryMixin / IComponentMixin interfaces remain - extend BaseApplication and call its registration methods directly.

Why direct methods over composed mixins?

  • One implementation, no drift between a mixin and the base class
  • Registration is available the moment you extend BaseApplication
  • The interfaces still express each capability for typed contracts

7. Controller Factory Pattern

ControllerFactory.defineCrudController() generates a complete CRUD controller from an entity definition. This reduces boilerplate while maintaining full customization.

Basic Usage:

typescript
const _Controller = ControllerFactory.defineCrudController({
  entity: () => User, // Entity class or resolver function
  repository: { name: UserRepository.name },
  controller: {
    name: 'UserController',
    basePath: '/users',
    // Default: { path: true, requestSchema: true }
    isStrict: { path: true, requestSchema: true },
  },
});

@controller({ path: '/users' })
export class UserController extends _Controller {
  constructor(
    @inject({ key: BindingKeys.build({ namespace: BindingNamespaces.REPOSITORY, key: UserRepository.name }) })
    repository: UserRepository,
  ) {
    super(repository);
  }
}

Per-Route Authentication:

typescript
const _Controller = ControllerFactory.defineCrudController({
  entity: () => User,
  repository: { name: UserRepository.name },
  controller: { name: 'UserController', basePath: '/users' },

  // Apply JWT to all routes by default
  authenticate: { strategies: [Authentication.STRATEGY_JWT] },

  // Override per-route
  routes: {
    // Public read endpoints
    find: { authenticate: { skip: true } },
    findById: { authenticate: { skip: true } },
    count: { authenticate: { skip: true } },

    // Protected write endpoints (use controller-level auth)
    create: {},
    updateById: {},
    deleteById: {},
  },
});

Custom Schemas Per Route:

typescript
const _Controller = ControllerFactory.defineCrudController({
  entity: () => User,
  repository: { name: UserRepository.name },
  controller: { name: 'UserController', basePath: '/users' },

  routes: {
    // Custom request body schema for create
    create: {
      authenticate: { strategies: [Authentication.STRATEGY_JWT] },
      request: {
        body: z.object({
          email: z.string().email(),
          name: z.string().min(2),
          // Exclude sensitive fields from client input
        }),
      },
    },

    // Custom response schema
    find: {
      authenticate: { skip: true },
      response: {
        schema: z.array(z.object({
          id: z.string(),
          name: z.string(),
          // Exclude internal fields from response
        })),
      },
    },
  },
});

Generated Routes:

RouteMethodPathDescription
countGET/countCount records matching filter
findGET/List records with filter
findByIdGET/{id}Get single record
findOneGET/find-oneGet first matching record
createPOST/Create new record
updateByIdPATCH/{id}Update record by ID
updateByPATCH/Bulk update by filter
deleteByIdDELETE/{id}Delete by ID
deleteByDELETE/Bulk delete by filter

TIP

TDataObject/TPersistObject cannot be inferred from entity - pass them explicitly for typed handlers: ControllerFactory.defineCrudController<TUserRecord>({ ... }). Use controller.enabledRoutes to whitelist routes and controller.readonly to disable all write routes.