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.
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:
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.