At Seasoned we are absolutely in love with Remix. We have been working with it for a few months and we really think it is a great tool.
But as with anything we'd use, we've found a few areas where we think the out-of-the-box approach falls short.
In this post, I'm gonna talk about some of them and show how we are solving those problems with our new library: domain-functions.
Plumbing
As soon as any project starts to get a little serious a demand for a structured architecture starts to emerge. On almost every example of Remix's actions and loaders out there, we can see some boilerplate we call plumbing.
For instance, take a look at the action on this chunk of code of the Jokes App. A lot is going on! And this is usually how Remix Apps look, relaying a lot of responsibility to the controllers.
Let's put the common loader/action's responsibilities in perspective:
- A) Extracting data out of the request, be it from the query string, the body, the url, request headers, etc.
- B) Assert that this data is in the right format, maybe doing some data munging after that.
- C) Doing some side effects, such as inserting data into the database, sending emails, etc.
- D) Returning a different
Response
depending on the result of previous steps. - E) There's also a need to manually maintain the types of your Responses aligned with what the UI expects, we'll talk more about it later in this post.
As you've probably guessed already, we think about loaders and actions as controllers. And we think controllers should only "speak HTTP". In that mindset, we'd refactor the list above to only steps A and D. Steps B and C are what we call Business logic, the code which makes the project unique.
And at Seasoned we like to separate this code into well defined/tested/typed domains.
So how would we decouple that business logic with domain-functions
?
First, we'd write Zod schemas for both the user input and the environment data:
// app/domains/jokes.server.ts
const jokeSchema = z.object({
name: z.string().min(2, `That joke's name is too short`),
content: z.string().min(10, 'That joke is too short'),
})
const userIdSchema = z.string().nonempty()
Then we'd write the business logic using those schemas:
// app/domains/jokes.server.ts
import { makeDomainFunction } from 'domain-functions'
// ...
const createJoke = makeDomainFunction(jokeSchema, userIdSchema)
((fields, jokesterId) =>
db.joke.create({ data: { ...fields, jokesterId } })
)
And lastly we'd write the controller's code:
// app/routes/jokes/new.tsx
import { inputFromForm } from 'domain-functions'
// ...
export async function action({ request }: ActionArgs) {
const result = await createJoke(
await inputFromForm(request),
await getUserId(request),
)
if (!result.success) {
return json(result, 400)
}
return redirect(`/jokes/${result.data.id}?redirectTo=/jokes/new`)
}
Now, let's rewrite this loader. We start with the domain:
// app/domains/jokes.server.ts
const getRandomJoke = makeDomainFunction(z.null(), userIdSchema)
(async (_i, jokesterId) => {
const count = await db.joke.count()
const skip = Math.floor(Math.random() * count)
return db.joke.findMany({ take: 1, skip, where: { jokesterId } })
})
Then, the loader:
// app/routes/jokes/index.tsx
export async function loader({ request }: LoaderArgs) {
const result = await getRandomJoke(
null,
await getUserId(request)
)
if (!result.success) {
throw new Response('No jokes to be found!', { status: 404 })
}
return json(result.data)
}
Do you see a pattern emerging on those controllers?
If you want to see the full Jokes App implemented with domain-functions
check this PR diff!.
Keeping a Pattern
With controllers doing so much, it is harder to keep a standard way to structure your successful and failed responses.
Should we be adding try/catch
in our controllers?
How are we going to return input errors and how to differ 'em from actual runtime or service errors?
domain-functions
does that for you, by having a structured way to present data and errors, you can be sure the responses will always be consistent. You also don't need to work with try/catch
blocks, we can actually just throw errors in our domain functions, the same pattern Remix uses internally, so you can only write the happy path of your domain functions and throw
errors to ensure type safety:
const getJoke = makeDomainFunction(z.object({ id: z.string().nonempty() }), userIdSchema)
(async ({ id }, jokesterId) => {
const joke = await db.joke.findOne({ where: { id, jokesterId } })
if (!joke) throw new Error('Joke not found')
return joke
})
On the domain function above, if successful the response will look like this:
const result = {
success: true,
data: { id: 1, name: 'Joke name', content: 'Joke content' },
inputErrors: [],
environmentErrors: [],
errors: [],
}
Otherwise it'll look like this:
const result = {
success: false,
inputErrors: [],
environmentErrors: [],
errors: [{ message: 'Joke not found' }],
}
Now that we reduced the boilerplate out of our actions/loaders and found an architectural pattern, it is easy to start creating our own little abstractions on top of them.
// app/lib/index.ts
function queryResponse<T>(result: T) {
if (!response.success)
throw new Response('Not found', { status: 404 })
return json<T>(result.data)
}
// app/routes/jokes/$id.tsx
export async function loader({ params }: LoaderArgs) {
return queryResponse(await getJoke(params))
}
Testing
Now imagine you need to thoroughly test that original code.
Currently, there's no easy way to do it, without kind of mocking the Router API. The solution usually lies in E2E testing.
We'd like to do unit and integration tests in our business logic, though. Now that our domain functions are just functions that receive data and return data, we can easily write them:
// Soon we'll be writing about how we set up our test database.
// For the sake of brevity, pretend there's already a DB with jokes
describe('getRandomJoke', () => {
it('returns a joke for the given userId', async () => {
const { user, jokes } = await setup()
const result = await getRandomJoke(null, user.id)
if (!result.success) throw new Error('No jokes to be found!')
expect(jokes.map(({ id }) => id)).toInclude(result.data.id)
})
})
If you ever tried to do such sort of testing on your Remix routes you are probably happy about what you've just seen.
Parsing structured data from Forms
Ok, this is not a Remix limitation but rather a "limitation" of the FormData
API.
It is often useful to parse structured data from forms, for example, when you have nested forms or repeatable fields.
FormData
can only work with flat structures and we need to know the structure of the data beforehand to know if we should call formData.get('myField')
or formData.getAll('myField')
. It arguably does not scale for complex forms.
By structured data I mean making the FormData
from this form:
<form method="post">
<input name="numbers[]" value="1" />
<input name="numbers[]" value="2" />
<input name="person[0][email]" value="john@doe.com" />
<input name="person[0][password]" value="1234" />
<button type="submit">
Submit
</button>
</form>
Be interpreted as this:
{
"numbers": ["1", "2"],
"person": [{ "email": "john@doe.com", "password": "1234" }]
}
Well, domain-functions
leverages qs
to do that conversion for you with inputFromForm
:
import { inputFromForm } from 'domain-functions'
const result = await myDomainFunction(await inputFromForm(request))
The library exposes other utilities for doing that sort of job.
End-to-end Type Safety and composition
One of the biggest complaints about Remix (and NextJS) is the lack of end-to-end type safety.
Having to maintain the types by hand is boring and prone to errors. We wanted an experience as good as tRPC and now that our domain functions have knowledge of your domain's I/O we are in the same situation as tRPC as stated by its author:
As for output typesātRPC trusts the return type inferred from your resolver which is reasonable IMO. These types largely originate from sources (Prisma queries, etc) that provide trustworthy types.
BTW: Colin is also the author of Zod and a bunch of nice projects, we can't overstate how much we like his projects.
When working with domain-functions
, you don't need to be writing types by hand as they are infered out of the domain functions. The following GIF shows the DX of this workflow:
Composition
When we started working on this project, we didn't realize we'd achieve such a good type safety nor did we planned to create a perfect arithmetic progression by always expressing the return of our Domain Functions as Promise<Result<MyData>>
.
So when we were confronted with the problem of working with multiple domains in a single loader without changing our controller's architecture, the answer was right in front of us: creating functions to compose multiple Domain Functions, resulting in... a Domain Function!
So far we've created 3 functions enabling us to code like this:
import { all, map, pipe } from 'domain-functions'
import { a, b, c, d, e } from './my-domain-functions.server'
// Transform the successful data, ex:
// { success: true, data: "foo" } => { success: true, data: true }
const x = map(a, Boolean)
// Create a domain function that is a sequence of Domain Functions
// where the output of one is the input of the next
const y = pipe(x, b, c)
// Create a domain function that will run all the given domain functions
// in parallel with `Promise.all`
const getData = all(d, e, y)
export async function loader({ params }: LoaderArgs) {
return queryResponse(await getData(params))
}
export default function Component() {
const [
dataForD, dataForE, dataForY
] = useLoaderData<typeof loader>()
// ...
}
All that logic and our loader's boilerplate is untouched!
This other GIF showcases the DX for compositions:
Conclusion
We are pretty excited about this project and we hope you'll like it.
We've been battle-testing it and can't wait to see the feedback of the community.
If you felt lost about the examples above, check out the documentation and examples of domain-functions
.
We are gonna be over the moon if it helps more teams to structure their projects and we are looking forward to your feedback/contributions.
Top comments (6)
Just so you know: remix moved their examples to its own repository, so the links to the jokes demo aren't working.
Thanks for pointing it out @protowalker !
I'm updating it right now
Great write up, Gustavo! Iām looking forward for taking remix-domains for a spin!
š Great!! Let us know how it goes!!
Hey Gustavo, how do handle error reporting to things like sentry etc when under the hood you are catching these and returning your structured response?
@timwaddell I work with Gustavo. We usually do no send any error from domain functions to sentry since they will have some handling mechanism. We have Sentry instrumentation on the express server to alert us about any error that is not caught (which excludes most errors inside domain functions).
If you want some sort of instrumentation for errors that happen inside the domain function I'd suggest create a function that could be composed with domain functions.
The type would look like:
With that type you could resolve the promise with the result and return another promise that will just send the success back to the caller or propagate the error to sentry upon resolution while returning the error.