Skip to content

Commit

Permalink
feat: enforce value object creation within entity boundaries (invaria…
Browse files Browse the repository at this point in the history
…nt ssot)
  • Loading branch information
adbayb committed Dec 15, 2024
1 parent 9b16917 commit 9bd2b0e
Show file tree
Hide file tree
Showing 9 changed files with 89 additions and 53 deletions.
2 changes: 1 addition & 1 deletion hosts/web/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
name="viewport"
content="width=device-width, initial-scale=1.0"
/>
<title>Vite + React + TS</title>
<title>Web</title>
</head>
<body>
<div id="root"></div>
Expand Down
20 changes: 9 additions & 11 deletions modules/catalog/src/adapters/QuoteEntityGateway.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
import { success } from "@clean-architecture/shared-kernel";
import type { IdValueObject } from "@clean-architecture/shared-kernel";

import type { QuoteEntityGatewayPort } from "../entities/QuoteEntityGatewayPort";
import { QuoteEntity } from "../entities/QuoteEntity";
import { AuthorValueObject } from "../entities/AuthorValueObject";

export class QuoteEntityGateway implements QuoteEntityGatewayPort {
public async getMany() {
Expand All @@ -12,17 +10,17 @@ export class QuoteEntityGateway implements QuoteEntityGatewayPort {
return success([]);
}

public async getOne(id: IdValueObject) {
public async getOne(id: string) {
await Promise.resolve();

// TODO: refacto to be internalized inside the QuoteEntity (to prevent anemic model)
const author = AuthorValueObject.create({
firstName: "test",
lastName: "test",
});

if (author.type === "failure") return author;
const fullName = "Test Test";
const [firstName, lastName] = fullName.split(" ") as [string, string];

return QuoteEntity.create(id, author.payload, "Fake content");
return QuoteEntity.create({
id,
content: "Fake content",
firstName,
lastName,
});
}
}
66 changes: 44 additions & 22 deletions modules/catalog/src/entities/QuoteEntity.ts
Original file line number Diff line number Diff line change
@@ -1,30 +1,52 @@
import { Entity, Guard, success } from "@clean-architecture/shared-kernel";
import type { IdValueObject } from "@clean-architecture/shared-kernel";
import {
Entity,
Guard,
IdValueObject,
success,
} from "@clean-architecture/shared-kernel";
import type { GetValueFromValueObject } from "@clean-architecture/shared-kernel";

import { CreatedAtValueObject } from "./CreatedAtValueObject";
import type { AuthorValueObject } from "./AuthorValueObject";

export class QuoteEntity extends Entity {
public createdAt: CreatedAtValueObject;

private constructor(
public override id: IdValueObject,
public author: AuthorValueObject,
public content: string,
) {
super(id);
this.createdAt = CreatedAtValueObject.create();
import { AuthorValueObject } from "./AuthorValueObject";

type QuoteEntityAttributes = {
id: IdValueObject;
author: AuthorValueObject;
content: string;
createdAt: CreatedAtValueObject;
};

type QuoteEntityCreateInput = GetValueFromValueObject<AuthorValueObject> &
Pick<QuoteEntityAttributes, "content"> & {
id: string;
};

export class QuoteEntity extends Entity<QuoteEntityAttributes> {
private constructor(public override attributes: QuoteEntityAttributes) {
super(attributes);
}

public static override create(
id: IdValueObject,
author: AuthorValueObject,
content: string,
) {
const guardResult = Guard.mustBeLessThanCharacters(content, 280);
public static override create({
id,
content,
firstName,
lastName,
}: QuoteEntityCreateInput) {
const guardContentResult = Guard.mustBeLessThanCharacters(content, 280);

if (guardContentResult.type === "failure") return guardContentResult;

const author = AuthorValueObject.create({ firstName, lastName });

if (guardResult.type === "failure") return guardResult;
if (author.type === "failure") return author;

return success(new QuoteEntity(id, author, content));
return success(
new QuoteEntity({
id: IdValueObject.create(id),
author: author.payload,
content,
createdAt: CreatedAtValueObject.create(),
}),
);
}
}
15 changes: 7 additions & 8 deletions modules/catalog/src/useCases/GetQuoteUseCase.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,4 @@
import {
IdValueObject,
UseCaseInteractor,
success,
} from "@clean-architecture/shared-kernel";
import { UseCaseInteractor, success } from "@clean-architecture/shared-kernel";
import type {
RequestModel,
ResponseModel,
Expand All @@ -24,14 +20,17 @@ export class GetQuoteUseCase extends UseCaseInteractor<
QuoteEntityGatewayPort
> {
public override async execute(requestModel: GetQuoteRequestModel) {
const id = IdValueObject.create(requestModel.id);
const entityGatewayResult = await this.entityGateway.getOne(id);
const entityGatewayResult = await this.entityGateway.getOne(
requestModel.id,
);

if (entityGatewayResult.type === "failure") {
this.presenter.error(entityGatewayResult);
} else {
this.presenter.ok(
success({ content: entityGatewayResult.payload.content }),
success({
content: entityGatewayResult.payload.attributes.content,
}),
);
}
}
Expand Down
26 changes: 18 additions & 8 deletions modules/shared-kernel/src/Entity.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,37 @@
import type { Result } from "@open-vanilla/result";

import { IdValueObject } from "./IdValueObject";
import type { GetValueFromValueObject } from "./ValueObject";
import type { IdValueObject } from "./IdValueObject";
import { Guard } from "./Guard";
import type { DomainObject } from "./DomainObject";

export abstract class Entity implements DomainObject {
protected constructor(
public id: IdValueObject = IdValueObject.create(crypto.randomUUID()),
) {}
type EntityAttributes = { id: IdValueObject };

public static create(..._: unknown[]): Entity | Result<Entity> {
export abstract class Entity<
Attributes extends EntityAttributes = EntityAttributes,
> implements DomainObject
{
protected constructor(public attributes: Attributes) {}

public static create(_input: {
id: GetValueFromValueObject<EntityAttributes["id"]>;
}): Entity | Result<Entity> {
throw new Error("NotImplementedException");
}

public static isInstanceOf(input: unknown): input is Entity {
return input instanceof Entity;
}

public equals(input: unknown) {
if (this === input) return true;

if (
!(input instanceof Entity) ||
!Entity.isInstanceOf(input) ||
Guard.mustBeDefinedAndNonNull(input).type === "failure"
)
return false;

return this.id.equals(input.id);
return this.attributes.id.equals(input.attributes.id);
}
}
5 changes: 4 additions & 1 deletion modules/shared-kernel/src/EntityGateway.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import type { Result } from "@open-vanilla/result";

import type { AnyRecord } from "./types";
import type { GetValueFromValueObject } from "./ValueObject";
import type { Entity } from "./Entity";

export type EntityGateway<
E extends Entity = Entity,
Methods = AnyRecord,
> = Methods & {
getMany: () => Promise<Result<E[]>>;
getOne: (id: E["id"]) => Promise<Result<E>>;
getOne: (
id: GetValueFromValueObject<E["attributes"]["id"]>,
) => Promise<Result<E>>;
};
2 changes: 1 addition & 1 deletion modules/shared-kernel/src/IdValueObject.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ type Value = string;
* if they are both strings but impossible if they are represented through a dedicated type)).
*/
export class IdValueObject extends ValueObject<Value> {
public static override create(input: Value) {
public static override create(input: Value = crypto.randomUUID()) {
return new IdValueObject(input);
}
}
5 changes: 4 additions & 1 deletion modules/shared-kernel/src/ValueObject.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ export abstract class ValueObject<Value> implements DomainObject {
public readonly value: Value;

public static create(
..._: unknown[]
_input: GetValueFromValueObject<ValueObject<unknown>>,
): Result<ValueObject<unknown>> | ValueObject<unknown> {
throw new Error("NotImplementedException");
}
Expand All @@ -42,3 +42,6 @@ export abstract class ValueObject<Value> implements DomainObject {
return JSON.stringify(this) === JSON.stringify(input);
}
}

export type GetValueFromValueObject<Input extends ValueObject<unknown>> =
Input["value"];
1 change: 1 addition & 0 deletions modules/shared-kernel/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export { Guard } from "./Guard";
export { IdValueObject } from "./IdValueObject";
export { Presenter } from "./Presenter";
export { UseCaseInteractor } from "./UseCase";
export type { GetValueFromValueObject } from "./ValueObject";
export { ValueObject } from "./ValueObject";
export type { RequestModel } from "./RequestModel";
export type { ResponseModel } from "./ResponseModel";
Expand Down

0 comments on commit 9bd2b0e

Please sign in to comment.