Persistent Layer: Models, DataSources, and Repositories
The persistent layer manages data using Drizzle ORM for type-safe database access and the Repository pattern for data abstraction.
Three main components:
- Models - Define data structure (static schema + relations on Entity class)
- DataSources - Manage database connections with auto-discovery
- Repositories - Provide CRUD operations with zero boilerplate
1. Models: Defining Your Data Structure
A model in Ignis is a single class with static properties for schema and relations. No separate variables needed.
Creating a Basic Model
// src/models/entities/user.model.ts
import { BaseEntity, extraUserColumns, generateIdColumnDefs, model } from '@venizia/ignis';
import { pgTable } from 'drizzle-orm/pg-core';
@model({ type: 'entity' })
export class User extends BaseEntity<typeof User.schema> {
// Define schema as static property
static override schema = pgTable('User', {
...generateIdColumnDefs({ id: { dataType: 'string' } }),
...extraUserColumns({ idType: 'string' }),
});
// Relations (empty array if none)
static override relations = () => [];
}Key points:
- Schema is defined inline as
static override schema - Relations are defined as
static override relations - No constructor needed - BaseEntity auto-discovers from static properties
- Type parameter uses
typeof User.schema(self-referencing)
Creating a Model with Relations
// src/models/entities/configuration.model.ts
import {
BaseEntity,
generateDataTypeColumnDefs,
generateIdColumnDefs,
generateTzColumnDefs,
generateUserAuditColumnDefs,
model,
RelationTypes,
TRelationConfig,
} from '@venizia/ignis';
import { foreignKey, index, pgTable, text, unique } from 'drizzle-orm/pg-core';
import { User } from './user.model';
@model({ type: 'entity' })
export class Configuration extends BaseEntity<typeof Configuration.schema> {
static override schema = pgTable(
'Configuration',
{
...generateIdColumnDefs({ id: { dataType: 'string' } }),
...generateTzColumnDefs(),
...generateDataTypeColumnDefs(),
...generateUserAuditColumnDefs({
created: { dataType: 'string', columnName: 'created_by' },
modified: { dataType: 'string', columnName: 'modified_by' },
}),
code: text('code').notNull(),
description: text('description'),
group: text('group').notNull(),
},
def => [
unique('UQ_Configuration_code').on(def.code),
index('IDX_Configuration_group').on(def.group),
foreignKey({
columns: [def.createdBy],
foreignColumns: [User.schema.id], // Reference User.schema, not a separate variable
name: 'FK_Configuration_createdBy_User_id',
}),
],
);
// Define relations using TRelationConfig array
static override relations = (): TRelationConfig[] => [
{
name: 'creator',
type: RelationTypes.ONE,
schema: User.schema,
metadata: {
fields: [Configuration.schema.createdBy],
references: [User.schema.id],
},
},
{
name: 'modifier',
type: RelationTypes.ONE,
schema: User.schema,
metadata: {
fields: [Configuration.schema.modifiedBy],
references: [User.schema.id],
},
},
];
}Key points:
- Relations use
TRelationConfig[]format directly - Reference other models via
Model.schema(e.g.,User.schema.id) - Relation names (
creator,modifier) are used in queries withinclude
Understanding Enrichers
Enrichers are helper functions that generate common database columns automatically.
Without enrichers:
static override schema = pgTable('User', {
id: uuid('id').defaultRandom().primaryKey(),
status: text('status').notNull().default('ACTIVE'),
createdBy: text('created_by'),
modifiedBy: text('modified_by'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
modifiedAt: timestamp('modified_at', { withTimezone: true }).notNull().defaultNow(),
// ... your fields
});With enrichers:
static override schema = pgTable('User', {
...generateIdColumnDefs({ id: { dataType: 'string' } }), // id (UUID)
...extraUserColumns({ idType: 'string' }), // status, audit fields, timestamps
// ... your fields
});Available Enrichers
| Enricher | Columns Added | Use Case |
|---|---|---|
generateIdColumnDefs() | id (UUID or number) | Every table |
generateTzColumnDefs() | createdAt, modifiedAt | Track timestamps |
generateUserAuditColumnDefs() | createdBy, modifiedBy | Track who created/updated |
generateDataTypeColumnDefs() | dataType, tValue, nValue, etc. | Configuration tables |
extraUserColumns() | Combines audit + status + type | Full-featured entities |
TIP
For a complete list of enrichers and options, see the Schema Enrichers Reference.
2. DataSources: Connecting to Your Database
A DataSource manages database connections and supports schema auto-discovery from repositories.
Creating a DataSource
// src/datasources/postgres.datasource.ts
import {
BaseDataSource,
datasource,
TNodePostgresConnector,
ValueOrPromise,
} from '@venizia/ignis';
import { drizzle } from 'drizzle-orm/node-postgres';
import { Pool } from 'pg';
interface IDSConfigs {
host: string;
port: number;
database: string;
user: string;
password: string;
}
@datasource({ driver: 'node-postgres' })
export class PostgresDataSource extends BaseDataSource<TNodePostgresConnector, IDSConfigs> {
constructor() {
super({
name: PostgresDataSource.name,
config: {
host: process.env.POSTGRES_HOST ?? 'localhost',
port: +(process.env.POSTGRES_PORT ?? 5432),
database: process.env.POSTGRES_DATABASE ?? 'mydb',
user: process.env.POSTGRES_USER ?? 'postgres',
password: process.env.POSTGRES_PASSWORD ?? '',
},
// No schema needed - auto-discovered from @repository bindings!
});
}
override configure(): ValueOrPromise<void> {
// getSchema() auto-discovers models from @repository bindings
const schema = this.getSchema();
this.logger.debug(
'[configure] Auto-discovered schema | Keys: %o',
Object.keys(schema),
);
const client = new Pool(this.settings);
this.connector = drizzle({ client, schema });
}
override getConnectionString(): ValueOrPromise<string> {
const { host, port, user, password, database } = this.settings;
return `postgresql://${user}:${password}@${host}:${port}/${database}`;
}
}How auto-discovery works:
@repositorydecorators register model-datasource bindings- When
configure()is called,getSchema()collects all bound models - Drizzle is initialized with the complete schema
Manual Schema (Optional)
If you need explicit control, you can still provide schema manually:
@datasource({ driver: 'node-postgres' })
export class PostgresDataSource extends BaseDataSource<TNodePostgresConnector, IDSConfigs> {
constructor() {
super({
name: PostgresDataSource.name,
config: { /* ... */ },
schema: {
User: User.schema,
Configuration: Configuration.schema,
// Add relations if using Drizzle's relational queries
},
});
}
}Registering a DataSource
// src/application.ts
export class Application extends BaseApplication {
preConfigure(): ValueOrPromise<void> {
this.dataSource(PostgresDataSource);
}
}3. Repositories: The Data Access Layer
Repositories provide type-safe CRUD operations. Use @repository decorator with both model and dataSource for auto-discovery.
Pattern 1: Zero Boilerplate (Recommended)
The simplest approach - everything is auto-resolved:
// src/repositories/configuration.repository.ts
import { Configuration } from '@/models/entities';
import { PostgresDataSource } from '@/datasources/postgres.datasource';
import { DefaultCRUDRepository, repository } from '@venizia/ignis';
@repository({
model: Configuration,
dataSource: PostgresDataSource,
})
export class ConfigurationRepository extends DefaultCRUDRepository<typeof Configuration.schema> {
// No constructor needed!
async findByCode(code: string) {
return this.findOne({ filter: { where: { code } } });
}
async findByGroup(group: string) {
return this.find({ filter: { where: { group } } });
}
}Pattern 2: Explicit @inject
When you need constructor control (e.g., read-only repository or additional dependencies):
// src/repositories/user.repository.ts
import { User } from '@/models/entities';
import { PostgresDataSource } from '@/datasources/postgres.datasource';
import { inject, ReadableRepository, repository } from '@venizia/ignis';
import { CacheService } from '@/services/cache.service';
@repository({ model: User, dataSource: PostgresDataSource })
export class UserRepository extends ReadableRepository<typeof User.schema> {
constructor(
// First parameter MUST be DataSource injection
@inject({ key: 'datasources.PostgresDataSource' })
dataSource: PostgresDataSource, // Must be concrete type, not 'any'
// After first arg, you can inject any additional dependencies
@inject({ key: 'some.cache' })
private cache: SomeCache,
) {
super(dataSource);
}
async findByRealm(realm: string) {
// Use injected dependencies
const cached = await this.cacheService.get(`user:realm:${realm}`);
if (cached) {
return cached;
}
return this.findOne({ filter: { where: { realm } } });
}
}Important:
- First constructor parameter MUST be the DataSource injection
- After the first argument, you can inject any additional dependencies you need
- When
@injectis at param index 0, auto-injection is skipped
Repository Types
| Type | Description |
|---|---|
DefaultCRUDRepository | Full read/write operations |
ReadableRepository | Read-only operations |
PersistableRepository | Write operations only |
Querying Data
const repo = this.get<ConfigurationRepository>({ key: 'repositories.ConfigurationRepository' });
// Find multiple records
const configs = await repo.find({
filter: {
where: { group: 'SYSTEM' },
limit: 10,
order: ['createdAt DESC'],
}
});
// Find one record
const config = await repo.findOne({
filter: { where: { code: 'APP_NAME' } }
});
// Create a record
const newConfig = await repo.create({
data: {
code: 'NEW_SETTING',
group: 'SYSTEM',
description: 'A new setting',
}
});
// Update by ID
await repo.updateById({
id: 'uuid-here',
data: { description: 'Updated description' }
});
// Delete by ID
await repo.deleteById({ id: 'uuid-here' });Querying with Relations
Use include to fetch related data. The relation name must match what you defined in static relations:
const configWithCreator = await repo.findOne({
filter: {
where: { code: 'APP_NAME' },
include: [{ relation: 'creator' }],
},
});
console.log('Created by:', configWithCreator.creator.name);Registering Repositories
// src/application.ts
export class Application extends BaseApplication {
preConfigure(): ValueOrPromise<void> {
this.dataSource(PostgresDataSource);
this.repository(UserRepository);
this.repository(ConfigurationRepository);
}
}4. Advanced Topics
Performance: Core API Optimization
Ignis automatically optimizes "flat" queries (no relations, no field selection) by using Drizzle's Core API. This provides ~15-20% faster queries for simple reads.
Transactions (Current)
Currently, use Drizzle's callback-based connector.transaction for atomic operations:
const ds = this.get<PostgresDataSource>({ key: 'datasources.PostgresDataSource' });
await ds.connector.transaction(async (tx) => {
await tx.insert(User.schema).values({ /* ... */ });
await tx.insert(Configuration.schema).values({ /* ... */ });
});Note: This callback-based approach requires all transaction logic to be in one callback. See Section 5 for the planned improvement.
Modular Persistence with Components
Bundle related persistence resources into Components for better organization:
export class UserManagementComponent extends BaseComponent {
override binding() {
this.application.dataSource(PostgresDataSource);
this.application.repository(UserRepository);
this.application.repository(ProfileRepository);
}
}5. Transactions (Planned)
Status: Planned - Not yet implemented. See full plan.
The Problem
Drizzle's callback-based transactions make it hard to pass transactions across services:
// Current: Everything must be inside the callback
await ds.connector.transaction(async (tx) => {
// Can't easily call other services with this tx
});Planned Solution
Loopback 4-style explicit transaction objects that can be passed anywhere:
// Start transaction from repository
const tx = await userRepo.beginTransaction({
isolationLevel: 'SERIALIZABLE' // Optional, defaults to 'READ COMMITTED'
});
try {
// Pass tx to multiple services/repositories
const user = await userRepo.create({ data, options: { transaction: tx } });
await profileRepo.create({ data: { userId: user.id }, options: { transaction: tx } });
await orderService.createInitialOrder(user.id, { transaction: tx });
await tx.commit();
} catch (err) {
await tx.rollback();
throw err;
}Isolation Levels
| Level | Description | Use Case |
|---|---|---|
READ COMMITTED | Default. Sees only committed data | General use |
REPEATABLE READ | Snapshot from transaction start | Reports, consistent reads |
SERIALIZABLE | Full isolation, may throw errors | Financial, critical data |
Benefits
| Aspect | Current (Callback) | Planned (Pass-through) |
|---|---|---|
| Service composition | Hard | Easy - pass tx anywhere |
| Separation of concerns | Services coupled | Services independent |
| Testing | Complex mocking | Easy to mock tx |
| Code organization | Nested callbacks | Flat, sequential |
Quick Reference
Model Template
import { BaseEntity, generateIdColumnDefs, model, TRelationConfig } from '@venizia/ignis';
import { pgTable, text } from 'drizzle-orm/pg-core';
@model({ type: 'entity' })
export class MyModel extends BaseEntity<typeof MyModel.schema> {
static override schema = pgTable('MyModel', {
...generateIdColumnDefs({ id: { dataType: 'string' } }),
name: text('name').notNull(),
});
static override relations = (): TRelationConfig[] => [];
}Repository Template
import { DefaultCRUDRepository, repository } from '@venizia/ignis';
import { MyModel } from '@/models/entities';
import { PostgresDataSource } from '@/datasources/postgres.datasource';
@repository({ model: MyModel, dataSource: PostgresDataSource })
export class MyModelRepository extends DefaultCRUDRepository<typeof MyModel.schema> {}Deep Dive: