Best Approach for multi-tenancy #65
Replies: 2 comments
-
I used this package: https://github.com/Avallone-io/rls |
Beta Was this translation helpful? Give feedback.
-
There is no silver bullet for multi tenancy, if you want RLS avallone is your best/quick choice, The example below is not complete and will help you understand the complexity of the changes if you want to go full tenancy, with a separate schema, database. you need to get teantId from controllers then cascade it to the repository, to do so you need,
example: controller async getAssetById(
@Param('id') id: string,
@TenantId() tenantId: string, // decorator used to resolve tenant id form url like operatorX.example.com --> split('.')[0]
): Promise<AssetHttpResponse> {
const query = new GetAssetQuery({ tenantId, id });
const result: Result<AssetEntity> = await this.queryBus.execute(query);
/* Returning Response classes which are responsible
for whitelisting data that is sent to the asset */
return new AssetHttpResponse(result.unwrap());
} src/libs/decorators/tenant-id.decorator.ts import { createParamDecorator, ExecutionContext } from '@nestjs/common';
function resolveOperatorNameFromURl(ctx: ExecutionContext) {
const req = ctx.switchToHttp().getRequest();
return req.headers.host.split('.')[0];
}
export const TenantId = createParamDecorator((data, ctx: ExecutionContext) => {
if (process.env.PROFILE === 'TEST') {
return process.env.DEFAULT_OPERATOR;
}
return resolveOperatorNameFromURl(ctx);
}); *** Query handler *** import { QueryHandlerBase } from '@libs/ddd/domain/base-classes/query-handler.base';
import { Result } from '@libs/ddd/domain/utils/result.util';
import { AssetEntity } from '@modules/asset/domain/entities/asset.entity';
import { AssetFactoryPort } from '@modules/asset/ports/asset-factory.port';
import { GetAssetQuery } from '@modules/asset/queries/get-asset/get-asset.query';
import { Inject } from '@nestjs/common';
import { QueryHandler } from '@nestjs/cqrs';
@QueryHandler(GetAssetQuery)
export class GetAssetQueryHandler extends QueryHandlerBase {
constructor(
@Inject('AssetFactoryPort')
private assetFactory: AssetFactoryPort, // assetFactory is a factory class we use to create tenant repositories/objects
) {
super();
}
/* Since this is a simple query with no additional business
logic involved, it bypasses application's core completely
and retrieves assets directly from a repository.
*/
async handle(query: GetAssetQuery): Promise<Result<AssetEntity>> {
const { tenantId } = query;
const asset = await this.assetFactory
.getAssetReadRepository(tenantId) // return a repository for a specefic teant id
.findOneByIdOrThrow(query.id);
return Result.ok(asset);
}
} Connection Provider import { typeormConfig } from '@config/ormconfig';
import { ParameterServicePort } from '@infrastructure/parameters/parameter.service.port';
import { Inject, Injectable } from '@nestjs/common';
import { Connection, createConnection, getConnectionManager } from 'typeorm';
import { PostgresConnectionOptions } from 'typeorm/driver/postgres/PostgresConnectionOptions';
@Injectable()
export class TenancyService {
protected tenantSchemas: Map<string, string> = new Map<string, string>();
constructor(
@Inject('ParameterServicePort')
private parameterService: ParameterServicePort,
) {}
async getTenantConnection(tenantId: string): Promise<Connection> {
const connectionManager = getConnectionManager();
const connectionName = tenantId;
const schema = await this.getSchemaName(tenantId);
if (connectionManager.has(connectionName)) {
const connection = connectionManager.get(connectionName);
return Promise.resolve(
connection.isConnected ? connection : connection.connect(),
);
}
return createConnection({
...(typeormConfig as PostgresConnectionOptions),
name: connectionName,
schema,
});
}
private async getSchemaName(tenantId: string): Promise<string> {
if (this.tenantSchemas.has(tenantId)) {
return this.tenantSchemas.get(tenantId) as string;
}
const tenantSchema = await this.parameterService.getOperatorSchema(
tenantId,
);
this.tenantSchemas.set(tenantId, tenantSchema);
return tenantSchema;
}
} typeorm.repository.base.ts import { NotFoundException } from '@exceptions';
import { AggregateRoot } from '@libs/ddd/domain/base-classes/aggregate-root.base';
import { DomainEvents } from '@libs/ddd/domain/domain-events';
import { LoggerPort } from '@libs/ddd/domain/ports/logger.port';
import {
DataWithPaginationMeta,
FindManyPaginatedParams,
QueryParams,
ReadRepositoryPort,
WriteRepositoryPort,
} from '@libs/ddd/domain/ports/repository.ports';
import { ID } from '@libs/ddd/domain/value-objects/id.value-object';
import { OrmMapper } from '@libs/ddd/infrastructure/database/base-classes/orm-mapper.base';
import { FindConditions, ObjectLiteral, Repository } from 'typeorm';
export type WhereCondition<OrmEntity> =
| FindConditions<OrmEntity>[]
| FindConditions<OrmEntity>
| ObjectLiteral
| string;
export abstract class TypeormRepositoryBase<
Entity extends AggregateRoot<unknown>,
EntityProps,
OrmEntity,
> implements
WriteRepositoryPort<Entity>,
ReadRepositoryPort<Entity, EntityProps>
{
protected constructor(
protected readonly tenantId: string,
protected readonly mapper: OrmMapper<Entity, OrmEntity>,
protected readonly logger: LoggerPort,
) {}
// protected readonly repository: Repository<OrmEntity>;
/**
* Specify relations to other tables.
* For example: `relations = ['user', ...]`
*/
protected abstract relations: string[];
abstract getRepository(tenantId: string): Promise<Repository<OrmEntity>>;
protected abstract prepareQuery(
params: QueryParams<EntityProps>,
): WhereCondition<OrmEntity>;
async save(entity: Entity): Promise<Entity> {
entity.validate(); // Protecting invariant before saving
const ormEntity = this.mapper.toOrmEntity(entity);
const result = await (
await this.getRepository(this.tenantId)
).save(ormEntity);
await DomainEvents.publishEvents(
entity.id,
this.logger,
this.correlationId,
);
this.logger.debug(
`[${entity.constructor.name}] persisted ${entity.id.value}`,
);
return this.mapper.toDomainEntity(result);
}
async saveMultiple(entities: Entity[]): Promise<Entity[]> {
const ormEntities = entities.map((entity) => {
entity.validate();
return this.mapper.toOrmEntity(entity);
});
const result = await (
await this.getRepository(this.tenantId)
).save(ormEntities);
await Promise.all(
entities.map((entity) =>
DomainEvents.publishEvents(entity.id, this.logger, this.correlationId),
),
);
this.logger.debug(
`[${entities}]: persisted ${entities.map((entity) => entity.id)}`,
);
return result.map((entity) => this.mapper.toDomainEntity(entity));
}
async findOne(
params: QueryParams<EntityProps> = {},
): Promise<Entity | undefined> {
const where = this.prepareQuery(params);
const found = await (
await this.getRepository(this.tenantId)
).findOne({
where,
relations: this.relations,
});
return found ? this.mapper.toDomainEntity(found) : undefined;
}
async findOneOrThrow(params: QueryParams<EntityProps> = {}): Promise<Entity> {
const found = await this.findOne(params);
if (!found) {
throw new NotFoundException();
}
return found;
}
async findOneByIdOrThrow(id: ID | string): Promise<Entity> {
const query = {
where: { id: id instanceof ID ? id.value : id },
};
const found = await (
await this.getRepository(this.tenantId)
).findOne(query);
if (!found) {
throw new NotFoundException();
}
return this.mapper.toDomainEntity(found);
}
async findMany(params: QueryParams<EntityProps> = {}): Promise<Entity[]> {
const result = await (
await this.getRepository(this.tenantId)
).find({
where: this.prepareQuery(params),
relations: this.relations,
});
return result.map((item) => this.mapper.toDomainEntity(item));
}
async findManyPaginated({
params = {},
pagination,
orderBy,
}: FindManyPaginatedParams<EntityProps>): Promise<
DataWithPaginationMeta<Entity>
> {
const [data, count] = await (
await this.getRepository(this.tenantId)
).findAndCount({
skip: pagination?.skip,
take: pagination?.limit,
where: this.prepareQuery(params),
order: orderBy,
relations: this.relations,
});
const result: DataWithPaginationMeta<Entity> = {
results: data.map((item) => this.mapper.toDomainEntity(item)),
nbResultsPerPage: count,
limit: pagination?.limit,
page: pagination?.page,
startCursor: '',
endCursor: '',
hasNextPage: false,
hasPreviousPage: false,
};
return result;
}
async delete(entity: Entity): Promise<Entity> {
entity.validate();
await (
await this.getRepository(this.tenantId)
).remove(this.mapper.toOrmEntity(entity));
await DomainEvents.publishEvents(
entity.id,
this.logger,
this.correlationId,
);
this.logger.debug(
`[${entity.constructor.name}] deleted ${entity.id.value}`,
);
return entity;
}
protected correlationId?: string;
setCorrelationId(correlationId: string): this {
this.correlationId = correlationId;
this.setContext();
return this;
}
private setContext() {
if (this.correlationId) {
this.logger.setContext(`${this.constructor.name}:${this.correlationId}`);
} else {
this.logger.setContext(this.constructor.name);
}
}
} And finally the impl of the repository import { TenancyService } from '@infrastructure/tenancy/tenancy.service';
import { LoggerPort } from '@libs/ddd/domain/ports/logger.port';
import { QueryParams } from '@libs/ddd/domain/ports/repository.ports';
import {
TypeormRepositoryBase,
WhereCondition,
} from '@libs/ddd/infrastructure/database/base-classes/typeorm.repository.base';
import { final } from '@libs/decorators/final.decorator';
import { prepareQueryProps } from '@libs/utils/prepare-query-props.util';
import { AssetOrmEntity } from '@modules/asset/database/asset.orm-entity';
import { AssetOrmMapper } from '@modules/asset/database/asset.orm-mapper';
import {
AssetEntity,
AssetProps,
} from '@modules/asset/domain/entities/asset.entity';
import { AssetReadRepositoryPort } from '@modules/asset/ports/asset.repository.port';
import { FindAssetsQuery } from '@modules/asset/queries/find-assets/find-assets.query';
import { Repository } from 'typeorm';
@final
export class AssetOrmRepository
extends TypeormRepositoryBase<AssetEntity, AssetProps, AssetOrmEntity>
implements AssetReadRepositoryPort
{
protected relations: string[] = [];
constructor(
tenantId: string,
protected logger: LoggerPort,
protected tenancyService: TenancyService, // inject tenancy service
) {
super(tenantId, new AssetOrmMapper(AssetEntity, AssetOrmEntity), logger);
}
async getRepository(tenantId: string): Promise<Repository<AssetOrmEntity>> {
return (
await this.tenancyService.getTenantConnection(tenantId) // create a connection using tenantId
).getRepository(AssetOrmEntity);
}
async findAssets(query: FindAssetsQuery): Promise<AssetEntity[]> {
const where: QueryParams<AssetOrmEntity> = prepareQueryProps(query);
const assets = await (
await this.getRepository(this.tenantId)
).find({ where });
return assets.map((asset) => this.mapper.toDomainEntity(asset));
}
// Used to construct a query
protected prepareQuery(
params: QueryParams<AssetProps>,
): WhereCondition<AssetOrmEntity> {
const where: QueryParams<AssetOrmEntity> = {};
if (params.name) {
where.name = params.name;
}
if (params.ownerId) {
where.ownerId = params.ownerId;
}
if (params.assetTypeId) {
where.assetTypeId = params.assetTypeId;
}
if (params.categoryId) {
where.categoryId = params.categoryId;
}
if (params.active !== undefined) {
where.active = params.active;
}
if (params.validated !== undefined) {
where.validated = params.validated;
}
return where;
}
} |
Beta Was this translation helpful? Give feedback.
-
What is the best approach for multi-tenancy on TypeORM?
Currently, I created a TenantModule (Credits to Esposito Medium post to create and change de TypeORM connection on the fly.
But this doesn't seem right, since this ties the TypeORM connection and other sources to a general module instead of let each data source handle its way to multitenancy.
What do you think is the best practice for this?
Beta Was this translation helpful? Give feedback.
All reactions