lloydrichards.dev

EFFECT

FUNCTIONAL-PROGRAMMING

CLEAN-ARCHITECTURE

February 07, 2025

Effect: Clean Architecture (Part 3)

Thoughts on how to use Effect in a clean architecture.

I think I was first introduced to Clean Architecture when I was working in Flutter and using fp-dart. Separating the code into layers has been a great way for me to rationalize my code and using Domain Driven Development (DDD) has further helped me decouple the logic of the application from the limitation sof the framework/technology.

I've noticed many times when introducing fp-ts to a project is amount of pushback you get from other developers that are not familiar with the library. Its hard to argue the benefits when you first need to teach or dictate a new way of thinking about programming. fp-ts has a tendency to bleed into the rest of the application. This means that regardless of the layering, there is still a fundamental level of functional programming that is needed to understand any part. In the past I've come up with strategies to mitigate this, so I would also like to see how this works in Effect.

Clean Architecture

What is Clean Architecture?

Clean Architecture is a software design pattern of separating the concerns of the application into layers. Keep the business logic separate from the implementation details of the application. This allows for the business logic to be tested and developed independently of the framework or technology used to build the application.

If the application is only build with a single framework then creating these layers can be easy. However, when you start to introduce other frameworks or technologies outside of the Infrastructure layer then it can get tricky. To solve this problem, I've tried to keep "frameworks" like React and Effect to only certain layers of a project. In the case of a NextJS application for this site, I've tried to limit the use of Effect to the Domain and Application (Use Cases) layers.

Effect
Hybrid
React

In the diagram above, I've tried to show how the layers are separated. The Domain layer contains a lot of Effect types such as Schema, Context, and any Error that might need to be handled. The Use Cases in the Application layer also use Effect to compose pipes that generate the data needed for the Presentation layer.

API Boundary

This boundary between the Application and Presentation is where I unwrap the Effect into a Promise and run it with Effect.runPromise. This is done using an exported map of functions that are used to fetch data from the Application layer:

api/index.ts
const ApiRuntime = ManagedRuntime.make(Layer.mergeAll(BunContext.layer));
 
export const api = {
  labs: {
    fetchAllLabs: async () => ApiRuntime.runPromise(getAllLabs),
    fetchFeaturedLabs: async () => ApiRuntime.runPromise(getFeaturedLabs),
  },
  projects: {
    fetchAllProjects: async () => ApiRuntime.runPromise(getAllProjects),
    fetchFeaturedProjects: async () =>
      ApiRuntime.runPromise(getFeaturedProjects),
    fetchProjectTeam: getTeamMembers,
    queryProjectBySlug: async (slug: string) =>
      ApiRuntime.runPromise(getProject(slug).pipe(Effect.either)),
    getProjectBySlug: async (slug: string) =>
      ApiRuntime.runPromise(getProject(slug)),
  },
  occupations: {
    fetchAllOccupations: async () => ApiRuntime.runPromise(getAllOccupations),
  },
  skills: {
    fetchSkillData: async () => ApiRuntime.runPromise(getSkillData),
  },
} as const;

Creating the ApiRuntime is a way to use a managed runtime and able to pass in the Context at the top level of the application. This is then used to run the Effect and return a Promise that can be used in the Presentation layer.

Most in useful part here is that I have the opportunity to handle errors in different ways while only defining the application logic once. For example; in the getProject use case its possible for the effect to fail with either parsing errors, importing, or simply not finding the project. In some of my api functions I might want React and Suspense to handle these errors in a graceful and performant way. But in other cases I might know that the error is unexpected and want to crash the application by rejecting the promise.

const getProject = Effect.Effect<
  [ReactElement, Project], //                        Success
  SystemError | ParseError | MissingContentError, // Failure
  BunContext.BunContext //                           Context
>;
 
// Throw a rejected promise on error
const canThrow = (slug: string) => ApiRuntime.runPromise(getProject(slug));
 
// Handle the error in the presentation layer
const canCatch = (slug: string) =>
  ApiRuntime.runPromise(getProject(slug).pipe(Effect.either));
 
// Fall back to a default value on error
const canDefault = (slug: string) =>
  ApiRuntime.runPromise(getProject(slug).pipe(Effect.orElse(defaultProject)));
 
// Log the error and continue
const canLog = (slug: string) =>
  ApiRuntime.runPromise(getProject(slug).pipe(Effect.orElseLog(console.error)));

This is incredibly powerful because it allows me to define the error handling strategy at the edge of the application where I need to pass the data to the presentation layer. This means that I can handle the errors in a way that is most appropriate for the context of the error.

Graceful Error Handling

In the presentation layer, its then possible to leverage either the Promise or the Either with the patterns in of both client and server side rendering. This can be done with Suspense and ErrorBoundary to handle the promise rejection. In the client components, the Either can be pattern matched on to handle the error in a more explicit way or provide feedback to the user through the UI.

// Using Suspense and ErrorBoundary to handle the promise rejection
const ProjectPage = async ({ slug }) => {
  const result = await api.canThrow(slug);
  return {};
};
 
const SuspenseExample = async ({ slug }) => {
  return (
    <ErrorBoundary fallback={<Error />}>
      <Suspense fallback={<Loading />}>
        <ProjectPage slug={slug} />
      </Suspense>
    </ErrorBoundary>
  );
};
// Using Either to redirect to a 404 page
const ProjectPage = async ({ slug }) => {
  const result = await api.canCatch(slug);
 
  if (Either.isLeft(result)) {
    if (result.left._tag == "MissingContentError") {
      return notFound();
    }
    throw new Error(result.left._tag);
  }
 
  return {};
};
// Using default value to fall back to a default project
const ProjectPage = async ({ slug }) => {
  const result = await api.canDefault(slug);
 
  return {};
};

Conclusion

Clean Architecture has been a great way for me to separate the concerns of my application. Using Effect in the Domain and Application layers has allowed me to take advantage of the functional programming features of Effect while still being able to use React in the Presentation layer. This has allowed me to handle errors in a way that is most appropriate for the context of the error.

I've been trying to implement as much of this in practise as possible with this site, so if you want to dive more into the code you can check out the GitHub repository for a closer look.