title | theme |
---|---|
Leveraging Clean Architecture For Enhanced Permission Management |
./theme/hublo.css |
A class should have only one reason to change.
It may look suitable for a developer to have only one method to get shifts from an hubler
class Hubler {
getShifts() {
// send shifts done by hubler
}
}
Getting shifts of a given hubler is not the same if we want to display the calendar or if we want to know their planning from an admin point of view as different rules may apply to retrieve them
class HublerPlanning {
getShifts() {
// send shifts done by hubler
}
}
class InstitutionHublerAvailabilities {
getShifts() {
// send shifts done by hubler in an institution
}
}
Software entities should be open for extension, but closed for modification.
class Shift {
post() {
// save in DB
this.notify();
}
notify() {
// send notification to hubler of the institution
}
}
class HubloPoolShift extends Shift {
notify() {
// send notification to hublo poolers
}
}
abstract class Shift {
post() {
// save in DB
this.notify();
}
abstract notify();
}
class HubloPoolShift extends Shift {
notify() {
// send notification to hublo pool
}
}
class NativeShift extends Shift {
notify() {
// send notification to hubler
}
}
Subtypes must be substitutable for their base types.
By weakening the return type
interface MissionPort {
findMission(): Promise<Mission>
}
class MissionRepository extends MissionPort {
findMission() {
const mission = this.missions.find(mission => mission.id === id)
// returns Promise<Mission | undefined> instead of Promise<Mission>
return Promise.resolve(mission);
}
}
By strengthening the preconditions
class Mission {
setIdMotif(idMotif: number) {
this.idMotif = idMotif;
}
}
class PrismaMission extends Mission {
setIdMotif(idMotif: number) {
if (idMotif === 1) {
throw new Error('Invalid idMotif');
}
this.idMotif = idMotif;
}
}
No client should be forced to depend on methods it does not use.
interface UserShiftService {
createShift();
getShift();
fillShift();
updateShift();
}
interface HublerShiftService {
getShift();
fillShift();
}
interface AdminShiftService {
createShift();
updateShift();
}
Depend on abstractions, not on concretions.
For each persistence we need to redefine a whole new service even if the business logic is the same
class ShiftOfferService {
constructor() {
this.db = new ShiftOfferMongoDB();
}
postShiftOffer() {
this.db.save();
}
}
class HubloPoolShiftOfferService extends ShiftOfferService {
constructor() {
this.db = new HubloPoolShiftOfferMongoDB();
}
}
class TestShiftOfferService extends ShiftOfferService {
constructor() {
this.db = new InMemoryDb();
}
}
You inject the accurate persistence layer directly
interface ShiftOfferRepository {
save();
}
class MongoDBShiftOfferRepository implements ShiftOfferRepository {
//...
}
class ShiftOfferService {
constructor(private db: ShiftOfferRepository) {}
postShiftOffer() {
this.db.save();
}
}
Don't base your data model on how it's actually stored
Don't comply to the framework. Use the framework to comply to your business
Don't tie your business to your interface
- Object that contain or have easy access to business data
- They implement the critical business rules that operate on that data
- They are not plain-old TS objects representing the DB schema
- They define the way an automated system is used
- They specify the inputs to be provided and outputs to be returned to the user
- They describe application-specific business rules
- They don't know in which environment they are running
Prosaic code is easier to read and understand
Use the same language in your code as in your business
Use a glossary to be sure everybody use the same words
Easily adapt to new requirements without significant rework.
"Embracing Clean Architecture and its no coupling philosophy means we can easily add or change any business rule"
Independent layers allow for testing of business logic without UI, database, or external dependencies.
"Clean Architecture allow us to use real implementations suitable for our tests that preserve all business rules instead of meaningless mocks"
Easily adapt to changes in the ecosystem.
"Embracing Clean Architecture means we can easily update or change any third-party dependency"
Code is the best documentation.
"The use of prosaic code and ubiquitous language within the business rules allow more clinical and standard specifications of the features before development and improve understanding for non-initiated people, techy or not techy."
Note: Clean Architecture's structured layers significantly improve the readability and navigability of our codebase. This clarity is especially beneficial for onboarding new team members and facilitating cross-team collaboration. When everyone can easily understand how the permissions framework fits within the larger system, it accelerates development and enhances the quality of our software. It's about making our codebase more accessible and manageable for everyone involved.
- Not a plain-old JS object representing a schema
- Encapsulation & SOLID → OPEN-CLOSE Principle (Software entities should be open for extension, but closed for modification)
- Implement critical business rules that operates on business data
// permission.entity.ts
export abstract class Permission extends SoftDeleteEntity {
// [...]
protected _category: PermissionCategory
get category() {
return this._category
}
organize(category: PermissionCategory) {
if (category.isMainCategory()) {
throw new InvalidPermissionCategoryHierarchyError()
}
this._category = category
}
}
- Prosaic language
- Describe application-specific business rules
- Delegate critical business rules to entities
- SOLID → ISP (No client should be forced to depend on methods it does not use), DIP (Depend on abstractions, not on concretions)
// create-permission.use-case.ts
@Injectable()
export class CreatePermission {
constructor(/** Dependency injection of port implementations, builder ...*/) {}
async execute(
input: CreatePermissionInput,
): Promise<PermissionDetailedOutput> {
const { id, scope, categoryId } = input
const { existingScope, existingCategory } = await this.retrieveData(scope, categoryId)
const newPermission = this.buildPermission(id, existingScope)
newPermission.organize(existingCategory)
const createdPermission = await this.permissionPort.create(newPermission)
return PermissionMapper.toDetailedOutput(createdPermission)
}
private async retrieveData(scope: string, categoryId: number) {
const [existingScope, existingCategory] = await Promise.all([
this.scopePort.findSingleById(scope),
this.categoryPort.findById(categoryId)
])
return { existingScope, existingCategory }
}
private buildPermission(id: string, scope: Scope): Permission {
return this.permissionBuilder.init().withId(id).withScope(scope).build()
}
- Decoupled: Port, can be used with various adapters as long as interface is respected
// Use Case ← PORT (interface) → Repository
// permission.port.ts
export interface PermissionPort {
/**
* @throws { PermissionNotFoundError }
*/
findById(id: string): Promise<Permission>
}
// grant-user-permission.use-case.ts
// [...]
await this.userPort.exists(userId)
const permission = await this.permissionPort.findById(permissionId)
const realScopeId = UserPermission.getFullScopeIdBasedOnPermissionScope({
relativeScopeId: relativeScopeId,
permissionScope: permission.scope,
})
const scope = await this.scopePort.findSingleById(realScopeId)
let userPermission = await this.userPermissionPort.findFirst({
scope,
userId,
})
if (userPermission) {
let permissionHasBeenAdded = false
if (!userPermission.permissions.some((p) => p.id === permission.id)) {
userPermission.permissions.push(permission)
permissionHasBeenAdded = true
}
if (permissionHasBeenAdded) {
await this.userPermissionPort.update(userPermission)
}
} else {
userPermission = this.userPermissionBuilder
.init()
.withPermissions([permission])
.withScope(scope)
.withUserId(userId)
.build()
await this.userPermissionPort.create(userPermission)
}
return UserPermissionMapper.toSpecificPermissionOutput(
permission,
true,
scope,
userId,
)
// [...]
// grant-user-permission.use-case.ts
// [...]
await this.checkUserExists(userId)
const { userPermission: existingUserPermission, permission, scope } = await this.retrieveData(userId, permissionId, relativeScopeId)
if (existingUserPermission) {
const permissionHasBeenAdded = existingUserPermission.injectPermission(permission)
if (permissionHasBeenAdded) {
await this.userPermissionPort.update(existingUserPermission)
}
} else {
const newUserPermission = this.buildUserPermission({ userId, permission, scope })
await this.userPermissionPort.create(newUserPermission)
}
return UserPermissionMapper.toSpecificPermissionOutput(permission, true, scope, userId)
// [...]
- DRY (decorator reusability)
- Declarative (meaningfull names for decorators, clear role of each)
- Controller: answers 3 questions (no other responsability),
- which kind of request can pass through.
- which is the form of the data structure received.
- which is the form of the data structure to be sent.
// reorganize-permission.controller.ts
@ApiTags('Permissions')
@UseInterceptors(PermissionErrorInterceptor)
@UseGuards(SuperAdminAuthGuard)
@Controller('permission')
export class ReorganizePermissionController {
constructor(private readonly useCase: ReorganizePermission) {}
@ApiRoute(/** */)
@ApiBearerAuth()
@Patch(':id/reorganize')
async reorganizePermission(
@Body() toUpdate: ReorganizePermissionBodyDto,
@Param() params: ReorganizePermissionParamsDto,
) {
const res = await this.useCase.execute({ ...toUpdate, ...params })
return plainToInstance(PermissionDetailedResponseDto, res)
}
}
Small pause: implemented different interesting patterns & tools ^^
- We choose a "blacklist" approach to avoud the
@Expose
&@Exclude
verbose decorators- Nestjs Plugin to avoid
@ApiProperty
decorators
export class ReorganizePermissionBodyDto {
@IsInt()
@Max(2147483647) // int4 max constraints (postgres)
@Min(0) // should be -2147483647, but we take 0 since it will not be necessary
newCategoryId: number
}
export class ReorganizePermissionParamsDto {
@IsString()
@IsNotEmpty()
id: string
}
- Adapter (respects the port interface)
- It interacts with the "external world" & returns a comprehensive expected data structure
- Here, an interesting pain point: in this case, a prisma object returned, how to convert it to an entity-business-like object as output
// permission.port.impl.ts
@Injectable()
export class PermissionPortImpl implements PermissionPort {
constructor(private readonly permissionRepository: PermissionRepository) {}
async findById(id: string): Promise<Permission> {
const permission = await this.permissionRepository.findPermissionById(id)
if (!permission) {
throw new PermissionNotFoundError(id)
}
return plainToInstance(PrismaPermission, permission)
}
// [...]
}
Knowing that...
- getters, setters in JS are not singly inherited (if one getter/setter exists and you decalre the missing one, it will not inherit, it will overwrite and ignore)
- Prisma & transformers needs the schema properties getters & setters both to be declared
How did might we have solved it?
- We did it in Rust
- Pushed a PR to nodejs org
- Trick JS
- La réponse D
🤔
- Prisma, the ORM we are using (db detail)
- Needs the schema properties getters & setters both to be declared
- Prisma compliant toJSON method allowing to map entity into prisma accepted fields
// permission.prisma.entity.ts
export class PrismaPermissionBase extends Permission implements PrismaAdapter {
get categoryId() {
return this._category?.id
}
set category(value: PrismaPermissionCategory) {
this._category = plainToInstance(PrismaPermissionCategory, value)
}
// prisma compliant toJSON method allowing to map entity into prisma accepted fields
toJSON() {
id: this._id,
scopeId: this._scope.displayFull(),
categoryId: this._category.id,
deletedAt: this._deletedAt,
}
}
export class PrismaPermission extends GetterSetterInheriter(
PrismaPermissionBase,
) {}
- Getters, setters in JS are not singly inherited (if one getter/setter exists and you decalre the missing one, it will not inherit, it will overwrite and ignore)
- So, why not to play with JS to achieve this?
- Tool that builds the parent/child tree and recovers getters/setters & and match them in an "inherited way" 😮
// prisma-injector.mixin.ts
export const GetterSetterInheriter = <TBase extends Constructor>(Base: TBase ) => {
return class extends Base {
constructor(...args: any[]) {
super(...args)
this.importGettersAndSetters()
}
getGettersAndSetters = (prototype: Constructor) => {
// [...]
const findAllGettersAndSetters = (
currentPrototype: Constructor,
aggregator: Record<string, {
get?: () => any;
set?: (_v: any) => void;
hasPrivateDeclaration?: boolean
}> = {}
) =>
// [...]
}
importGettersAndSetters = () => {
const extendedClassPrototype: Constructor = Object.getPrototypeOf(this)
const gettersSetters = this.getGettersAndSetters(extendedClassPrototype)
// [...]
}
}
}
Knowing that... there are several strategies to test where interactions with a db is needed like mocking (fake calls/results), in memory dbs, infrastructure db to test
Which strategy do you thing we might have taken?
- Who tests nowadays?
- Mock
- In memory db
- Infrastructure db
- La réponse D
🤔
- Adapter (respects the port interface)
- In memory storage of state (entities)
// permission.port.in-memory.ts
export class InMemoryPermissionPort
extends InMemoryBasePortMixin<Permission>({
NotFoundError: PermissionNotFoundError,
AlreadyExistsError: PermissionAlreadyExistsError,
})
implements PermissionPort
{
findAll(): Promise<Permission[]> {
return Promise.resolve(this.entities)
}
}
- No mocks, in memory state 😮
- Exemple, preparation of getall permissions
- We prepare it by first creating a category which is needed on a permission,
- then building the permission stub
- finally, assigning it to the in-memory db
describe('GetAllPermissionsUseCase', () => {
let permissionPort: InMemoryPermissionPort
let useCase: GetAllPermissions
describe('execute', () => {
beforeEach(() => {
const parentCategory = PermissionCategoryStubFactory.createMainCategory({
id: 1,
})
permissionPort = new InMemoryPermissionPort([
new PermissionStub({
id: 'permission-id',
scope: new ScopeStub({ id: 'permission-name' }),
category: new PermissionCategoryStub({
id: 2,
labelKey: 'old-category-name',
parentCategory: parentCategory,
}),
}),
])
useCase = new GetAllPermissions(permissionPort)
})
// [...]
Q & A ?