Skip to content

Instantly share code, notes, and snippets.

@martinsandredev
Last active June 11, 2024 01:12
Show Gist options
  • Save martinsandredev/6b640daa4111b5ca05b93a18a5050b2a to your computer and use it in GitHub Desktop.
Save martinsandredev/6b640daa4111b5ca05b93a18a5050b2a to your computer and use it in GitHub Desktop.
Clean Architecture and Functional Programming with ZIO inspired Effect-TS
import * as E from '@effect-ts/core/Either';
import * as O from '@effect-ts/core/Option';
import * as T from '@effect-ts/core/Effect';
import * as L from '@effect-ts/core/Effect/Layer';
import * as M from '@effect-ts/core/Effect/Managed';
import * as HashMap from '@effect-ts/core/Collections/Mutable/HashMap';
import { pipe, flow } from '@effect-ts/core/Function';
import { tag } from '@effect-ts/system/Has';
import express, { Request, Response, Router, Express } from 'express';
import { v4 as uuidv4 } from 'uuid';
/* DOMAIN */
class DomainError extends Error {
constructor(message: string) {
super(message);
}
}
class AccountNotFound extends DomainError {
constructor() {
super(`Account not found`);
}
}
class Guard {
static againstNil<T>(value: T) {
if (value === null || value === undefined) {
return E.left(new Error('Cannot be nil'));
}
return E.right(value);
}
static atLeast(size: number) {
return <T extends string | Array<any>>(value: T) => {
if (value.length < size) {
return E.left(new Error(`Size cannot be smaller than ${size}`));
}
return E.right(value);
};
}
static hasTheSize(size: number) {
return <T extends string | Array<any>>(value: T) => {
if (value.length !== size) {
return E.left(new Error(`Size must be exactly ${size}`));
}
return E.right(value);
};
}
}
export class Guid {
private constructor(private readonly value: string) {}
toString() {
return this.value;
}
static fromString(value: string): E.Either<Error, Guid> {
return pipe(
Guard.againstNil(value),
E.chain(Guard.hasTheSize(36)),
E.map((s) => new Guid(s)),
E.mapLeft((e) => new DomainError(`Guid - ${e.message}`))
);
}
static random(): T.UIO<Guid> {
return pipe(
T.succeedWith(() => uuidv4()),
T.map((s) => new Guid(s))
);
}
equals<T extends Record<any, any>>(o: T): boolean {
if (o === null || o === undefined) {
return false;
}
if (this === o) {
return true;
}
if (o instanceof Guid) {
return o.toString() === this.toString();
}
return false;
}
}
export class AccountId {
private constructor(private readonly value: Guid) {}
get() {
return this.value;
}
toString() {
return `AccountId(${this.get().toString()})`;
}
static create(value: Guid): E.Either<Error, AccountId> {
return pipe(
Guard.againstNil(value),
E.map((id) => new AccountId(id)),
E.mapLeft((e) => new DomainError(`AccountId - ${e.message}`))
);
}
equals<T extends Record<any, any>>(o: T): boolean {
if (o === null || o === undefined) {
return false;
}
if (this === o) {
return true;
}
if (o instanceof AccountId) {
return o.get().equals(this.get());
}
return false;
}
}
export class AccountName {
private constructor(private readonly value: string) {}
get() {
return this.value;
}
static create(value: string): E.Either<Error, AccountName> {
return pipe(
Guard.againstNil(value),
E.chain(Guard.atLeast(3)),
E.map((s) => new AccountName(s)),
E.mapLeft((e) => new DomainError(`AccountName - ${e.message}`))
);
}
equals<T extends Record<any, any>>(o: T): boolean {
if (o === null || o === undefined) {
return false;
}
if (this === o) {
return true;
}
if (o instanceof AccountName) {
return o.get() === this.get();
}
return false;
}
}
export class Account {
private constructor(private readonly id: AccountId, private readonly name: AccountName) {}
getId() {
return this.id;
}
getName() {
return this.name;
}
static create(id: AccountId, name: AccountName): E.Either<Error, Account> {
return pipe(
E.do,
E.bind('id', () =>
pipe(
id,
Guard.againstNil,
E.mapLeft((e) => new DomainError(`Account: Id - ${e.message}`))
)
),
E.bind('name', () =>
pipe(
name,
Guard.againstNil,
E.mapLeft((e) => new DomainError(`Account: Name - ${e.message}`))
)
),
E.map(({ id, name }) => new Account(id, name))
);
}
equals<T extends Record<any, any>>(o: T): boolean {
if (o === null || o === undefined) {
return false;
}
if (this === o) {
return true;
}
if (o instanceof Account) {
return o.getName().equals(this.getName()) && o.getId().equals(this.getId());
}
return false;
}
}
/* APPLICATION */
interface Logger {
startsAt(): T.UIO<void>;
log<T>(data: T): T.UIO<void>;
error<T>(data: T): T.UIO<void>;
endsAt(): T.UIO<void>;
}
interface AccountRepository {
findById(id: AccountId): T.IO<Error, O.Option<Account>>;
save(account: Account): T.IO<Error, void>;
}
interface GetAccountByIdRequest {
id: string;
}
interface CreateAccountRequest {
name: string;
}
interface AccountResponse {
id: string;
name: string;
}
function mapAccountToOutput(account: Account) {
return {
id: account.getId().get().toString(),
name: account.getName().get()
};
}
class GetAccountByIdUseCase {
constructor(private readonly accountRepository: AccountRepository) {}
execute(input: GetAccountByIdRequest): T.IO<Error, AccountResponse> {
return pipe(
T.do,
T.bind('id', () => T.fromEither(() => Guid.fromString(input.id))),
T.bind('accountId', ({ id }) => T.fromEither(() => AccountId.create(id))),
T.bind('account', ({ accountId }) =>
pipe(
this.accountRepository.findById(accountId),
T.chain(
flow(
T.getOrFail,
T.mapError(() => new AccountNotFound())
)
)
)
),
T.map(({ account }) => mapAccountToOutput(account))
);
}
}
class CreateAccountUseCase {
constructor(private readonly logger: Logger, private readonly accountRepository: AccountRepository) {}
private createAccount(id: Guid, dto: CreateAccountRequest) {
return pipe(
E.do,
E.bind('accountId', () => AccountId.create(id)),
E.bind('accountName', () => AccountName.create(dto.name)),
E.chain(({ accountId, accountName }) => Account.create(accountId, accountName))
);
}
execute(input: CreateAccountRequest): T.IO<Error, AccountResponse> {
return pipe(
T.do,
T.tap(() => this.logger.startsAt()),
T.tap(() => this.logger.log('Creating Account...')),
T.bind('id', () => Guid.random()),
T.bind('account', ({ id }) => T.fromEither(() => this.createAccount(id, input))),
T.tap(({ account }) => this.accountRepository.save(account)),
T.tap(({ account }) => this.logger.log(account)),
T.map(({ account }) => mapAccountToOutput(account)),
T.tap(() => this.logger.log('Account created!')),
T.tapError((e) => pipe(this.logger.log('Account not created!'), T.zip(this.logger.error(e)))),
T.onExit(() => this.logger.endsAt())
);
}
}
const Logger = tag<Logger>();
const AccountRepository = tag<AccountRepository>();
/* INFRA */
function getAccountByIdController(req: Request<GetAccountByIdRequest>, res: Response) {
return pipe(
T.do,
T.bind('accountRepository', () => T.service(AccountRepository)),
T.let('request', () => ({ id: req.params.id })),
T.let('useCase', ({ accountRepository }) => new GetAccountByIdUseCase(accountRepository)),
T.bind('response', ({ useCase, request }) => useCase.execute(request)),
T.chain(({ response }) => T.succeedWith(() => res.status(200).json(response).send()))
);
}
function createAccountController(req: Request<any, any, CreateAccountRequest>, res: Response) {
return pipe(
T.do,
T.bind('logger', () => T.service(Logger)),
T.bind('accountRepository', () => T.service(AccountRepository)),
T.let('useCase', ({ logger, accountRepository }) => new CreateAccountUseCase(logger, accountRepository)),
T.let('request', () => ({ name: req.body.name })),
T.bind('response', ({ useCase, request }) => useCase.execute(request)),
T.chain(({ response }) => T.succeedWith(() => res.status(201).json(response).send()))
);
}
class SimpleLogger implements Logger {
startsAt(): T.UIO<void> {
return pipe(
T.succeedWith(() => new Date()),
T.chain((date) =>
T.succeedWith(() => {
console.group();
console.log(`Logging starts at: ${date.toISOString()}`);
})
)
);
}
log<T>(data: T) {
return T.succeedWith(() => console.log(data));
}
error<T>(data: T) {
return T.succeedWith(() => console.error(data));
}
endsAt(): T.UIO<void> {
return pipe(
T.succeedWith(() => new Date()),
T.chain((date) =>
T.succeedWith(() => {
console.log(`Logging ends at: ${date.toISOString()}`);
console.groupEnd();
})
)
);
}
}
class InMemoryAccountRepository implements AccountRepository {
private readonly data = HashMap.make() as HashMap.HashMap<string, Account>;
findById(id: AccountId) {
return T.succeedWith(() => pipe(this.data, HashMap.get(id.get().toString())));
}
save(account: Account) {
return T.succeedWith(() => pipe(this.data, HashMap.set(account.getId().get().toString(), account)));
}
}
function mapErrorToPresenter<E extends Error>(e: E): [number, { message: string }] {
const httpStatus = e instanceof DomainError ? 412 : 500;
return [
httpStatus,
{
message: e.message
}
];
}
function HttpHandler<Ef extends (req: Request<any, any, any, any, any>, res: Response) => T.Effect<any, any, any>>(controller: Ef) {
return (req: Parameters<Ef>['0'], res: Parameters<Ef>['1']) =>
pipe(
controller(req, res),
T.chainError((error) =>
T.succeedWith(() => {
const [httpStatus, body] = mapErrorToPresenter(error);
return res.status(httpStatus).json(body).send();
})
)
);
}
function AccountModule(app: Express) {
const LoggerLive = L.fromManaged(Logger)(M.succeed(new SimpleLogger()));
const AccountRepositoryLive = L.fromManaged(AccountRepository)(M.succeed(new InMemoryAccountRepository()));
const AccountLive = L.all(LoggerLive, AccountRepositoryLive);
const AccountHandlers = {
get: flow(HttpHandler(getAccountByIdController), T.provideSomeLayer(AccountLive), T.run),
create: flow(HttpHandler(createAccountController), T.provideSomeLayer(AccountLive), T.run)
};
const accountRouter = Router();
accountRouter.get('/:id', AccountHandlers.get);
accountRouter.post('/', AccountHandlers.create);
app.use('/account', accountRouter);
}
function init() {
const port = 3000;
const app = express();
app.use(express.urlencoded({ extended: false }));
app.use(express.json());
AccountModule(app);
app.listen(port, () => {
console.log(`Example app listening at http://localhost:${port}`);
});
}
init();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment