Last active
June 11, 2024 01:12
-
-
Save martinsandredev/6b640daa4111b5ca05b93a18a5050b2a to your computer and use it in GitHub Desktop.
Clean Architecture and Functional Programming with ZIO inspired Effect-TS
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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