Upgrade to Pro — share decks privately, control downloads, hide ads and more …

[EN] Robust and Scalable API Gateway Built on Effect

[EN] Robust and Scalable API Gateway Built on Effect

June 4, 2024 @ TSKaigi 2024 After Talk

Yuichi Goto

June 04, 2024
Tweet

More Decks by Yuichi Goto

Other Decks in Programming

Transcript

  1. Robust and Scalable API Gateway Built on Effect Yuichi Goto

    | June 2024 at TSKaigi 2024 After Talk
  2. Who am I?  Yuichi Goto  @_yasaichi  @yasaichi

     Senior SWE at EARTHBRAIN  Co-author of Ruby on Rails book 2 2
  3. EB offers a novel hardware/software solution for the construction industry

    Image source: https://smartconstruction.com/ 3 3
  4. Background and Purpose This talk is based on my experience

    in the recent migration project of a legacy API gateway that is now in production. The new API gateway is built on Deno, NestJS, and Effect. In this talk, I am going to Give an overview of Effect. Share the motivations, results, and key takeaways for adopting Effect in the project. 4 4
  5. The original talk at TSKaigi in 5 mins (too short)

    https://speakerdeck.com/yasaichi/robust-and-scalable-api-gateway-built-on-effect 5 5
  6. What is Effect? Effect is a new TypeScript library that

    has just reached the stable release (v3) in April 2024 [1]. In theory, it provides the Effect System [2], one of the research topics in computer science [3]. In practice, it enables to build typed, robust, and scalable applications. It also provides standard-library like features such as Either type, Immutable Data Structures, Pattern Matching, etc. 7 7
  7. Define and Create Effect s The type definition is Effect<Success,

    Error, Requirements> . From its type, you can think of it as a “smart Promise” that can explain both the success and error types. You can create effects From functions: Effect.sync , Effect.tryPromise , Effect.gen From the other values: Effect.succeed , Effect.fail 9 9
  8. Compose and Run Effect s You can compose effects using

    pipe or yield* , which is similar to then and await in Promise. However, you must explicitly run effects using the following methods, which is different from Promise. Effect.run(Sync|Promise) returns a value or throws an error. Effect.run(Sync|Promise)Exit returns a value or an error. 10 10
  9. import { Effect } from 'effect'; const remainder = (n:

    number, d: number): Effect.Effect<number, Error> => !isFinite(n) || !isFinite(d) || d === 0 ? Effect.fail(new Error('Cannot calculate the remainder')) : Effect.succeed(n % d); // NOTE: The error type is automatically inferred from `remainder`. const isEven = (n: number): Effect.Effect<boolean, Error> => remainder(n, 2).pipe(Effect.andThen((r) => r === 0)); Effect.runSync(isEven(42)); // true Effect.runSync(isEven(NaN)); // Error: Cannot calculate the remainder e.g. Remainder and isEven functions using Effect 11 11
  10. Motivation To solve a trade-off between the following things that

    result from I/O operations in the API gateway, I decided to use Effect. Robustness and Scalability The naive implementation is easy to maintain, but not very robust or scalable. Code maintainability Efforts to improve robustness and scalability result in less maintainability of the codebase. 13 13
  11. export class UsersService { constructor( private readonly postApiService: PostApiService, private

    readonly userApiService: UserApiService, ) {} async findOne(id: number): Promise<FindUserResponseDto> { try { const user = await this.userApiService.getUserById({ userId: id }); const posts = await this.postApiService.getPosts({ userId: id, limit: 5 }); return { id: user.id, username: user.username, latestPosts: posts.map((post) => ({ id: post.id, title: post.title })), }; } catch (error) { if (error instanceof ApiException && error.code === 404) { throw new NotFoundException('User not found', { cause: error }); } throw error; } } } e.g. Retrieving a user and their latest posts * user and posts are just for explanation and don't appear in the real codebase. 14 14
  12. // Before const user = await this.userApiService.getUserById({ userId: id });

    // After const user = await retry( async () => { try { return await this.userApiService.getUserById({ userId: id }); } catch (error) { if (error instanceof ApiException && error.code === 404) { throw new AbortError( new NotFoundException('User not found', { cause: error }), ); } throw error; } }, { retries: 3 }, ); Improvement in robustness 15 15
  13. // Before const user = await this.userApiService.getUserById({ userId: id });

    const posts = await this.postApiService.getPosts({ userId: id, limit: 5 }); // After const [user, posts] = await Promise.all([ this.userApiService.getUserById({ userId: id }), this.postApiService.getPosts({ userId: id, limit: 5 }), ]); Improvement in scalability 16 16
  14. async findOne(id: number): Promise<FindUserResponseDto> { const [user, posts] = await

    Promise.all([ retry( async () => { try { return await this.userApiService.getUserById({ userId: id }); } catch (error) { if (error instanceof ApiException && error.code === 404) { throw new AbortError( new NotFoundException('User not found', { cause: error }), ); } throw error; } }, { retries: 3 }, ), retry( () => this.postApiService.getPosts({ userId: id, limit: 5 }), { retries: 3 }, ), ]); return { id: user.id, username: user.username, latestPosts: posts.map((post) => ({ id: post.id, title: post.title })), }; } ... leads to less maintainability 17 17
  15. findOne(id: number): Effect.Effect<FindUserResponseDto, NotFoundException> { return Effect.all( [ Effect .tryPromise(()

    => this.userApiService.getUserById({ userId: id })) .pipe(Effect.retry({ until: (error) => error instanceof ApiException && error.code === 404, times: 3, })), Effect .tryPromise(() => this.postApiService.getPosts({ userId: id, limit: 5 })) .pipe(Effect.retry({ times: 3 })), ], { concurrency: 'inherit' }, ).pipe( Effect.catchAll(({ error }) => error instanceof ApiException && error.code === 404 ? Effect.fail(new NotFoundException('User not found', { cause: error })) : Effect.die(error) ), Effect.andThen(([user, posts]) => ({ id: user.id, username: user.username, latestPosts: posts.map((post) => ({ id: post.id, title: post.title })), })), ); } Result: Well-organized multiple concerns 19 19
  16. findOne(id: number): Effect.Effect<FindUserResponseDto, NotFoundException> { Effect .tryPromise(() => this.userApiService.getUserById({ userId:

    id })) Effect .tryPromise(() => this.postApiService.getPosts({ userId: id, limit: 5 })) Effect.andThen(([user, posts]) => ({ id: user.id, username: user.username, latestPosts: posts.map((post) => ({ id: post.id, title: post.title })), })), } 1. API Requests and Response composition 20 20
  17. findOne(id: number): Effect.Effect<FindUserResponseDto, NotFoundException> { .pipe(Effect.retry({ until: (error) => error

    instanceof ApiException && error.code === 404, times: 3, })), .pipe(Effect.retry({ times: 3 })), Effect.catchAll(({ error }) => error instanceof ApiException && error.code === 404 ? Effect.fail(new NotFoundException('User not found', { cause: error })) : Effect.die(error) } 2. Retrying API requests and Error handling 21 21
  18. findOne(id: number): Effect.Effect<FindUserResponseDto, NotFoundException> { return Effect.all( [ ], {

    concurrency: 'inherit' }, ).pipe( ); } 3. Parallelizing API requests 22 22
  19. Key Takeaways I ended up stopping using Effect in the

    whole codebase and decided to use it only in certain parts (e.g. Guard in NestJS). This was because the project members were giving negative feedback: “Difficult to read and write”. I did not expect that introducing the piping style would be as difficult as understanding the Effect concept. The Effect contributors understand these difficulties so well that they have done a lot of work around them. 23 23
  20. // async/await const add = async () => { const

    x = await Promise.resolve(1); const y = await Promise.resolve(2); return x + y; }; // function*/yield* const add = Effect.gen(function* () { const x = yield* Effect.succeed(1); const y = yield* Effect.succeed(2); return x + y; }); Generators bring the same mental model as async/await 25 25
  21. findOne(id: number): Effect.Effect<FindUserResponseDto, NotFoundException> { return Effect.gen(this, function* () {

    const [user, posts] = yield* Effect.all( [ Effect.retry( Effect.tryPromise(() => this.userApiService.getUserById({ userId: id })), { until: (error) => error instanceof ApiException && error.code === 404, times: 3, }, ), Effect.retry( Effect.tryPromise(() => this.postApiService.getPosts({ userId: id, limit: 5 })), { times: 3 }, ), ], { concurrency: 'inherit' }, ); return { id: user.id, username: user.username, latestPosts: posts.map((post) => ({ id: post.id, title: post.title })), }; }).pipe( Effect.catchAll(({ error }) => error instanceof ApiException && error.code === 404 ? Effect.fail(new NotFoundException('User not found', { cause: error })) : Effect.die(error) ), ); } Future work: Effect with Generators in the whole codebase 26 26
  22. Conclusion Effect is a new library that provides a foundation

    for building a typed, robust, and scalable application in TypeScript. I adopted it to solve the trade-off between “robustness and scalability” and “code maintainability” in the recent project. I ended up using it only in the certain parts because introducing the piping style was also difficult for the project members. I am sure that Effect will get better because the contributors have done a lot of work around this difficulty. 28 28
  23. References 1. Effect 3.0 – Effect Blog,URL: https://effect.website/blog/effect-3.0 2. effect/README.md

    at e9875da3732bc67bb62789f2850d34abe7eb873d · Effect-TS/effect,URL: htt ps://github.com/Effect-TS/effect/blob/e9875da3732bc67bb62789f2850d34abe7eb873d/README. md#effect 3. Nielson, F., & Nielson, H.R. (1999). Type and Effect Systems. Correct System Design. 4. Release [email protected] · Effect-TS/effect,URL: https://github.com/Effect-TS/effect/releases/tag/effe ct%403.0.4 31 31