DEV Community

Cover image for How domain-functions improves the (already awesome) DX of Remix projects?
Gustavo Guichard (Guga) for Seasoned

Posted on • Edited on

How domain-functions improves the (already awesome) DX of Remix projects?

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()


Enter fullscreen mode Exit fullscreen mode

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 } })
  )


Enter fullscreen mode Exit fullscreen mode

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`)
}


Enter fullscreen mode Exit fullscreen mode

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 } })
  })


Enter fullscreen mode Exit fullscreen mode

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)
}


Enter fullscreen mode Exit fullscreen mode

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
  })


Enter fullscreen mode Exit fullscreen mode

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: [],
}


Enter fullscreen mode Exit fullscreen mode

Otherwise it'll look like this:



const result = {
  success: false,
  inputErrors: [],
  environmentErrors: [],
  errors: [{ message: 'Joke not found' }],
}


Enter fullscreen mode Exit fullscreen mode

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))
}


Enter fullscreen mode Exit fullscreen mode

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)
  })
})


Enter fullscreen mode Exit fullscreen mode

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>


Enter fullscreen mode Exit fullscreen mode

Be interpreted as this:



{
  "numbers": ["1", "2"],
  "person": [{ "email": "john@doe.com", "password": "1234" }]
}


Enter fullscreen mode Exit fullscreen mode

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))


Enter fullscreen mode Exit fullscreen mode

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:

type safety

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>()
  // ...
}


Enter fullscreen mode Exit fullscreen mode

All that logic and our loader's boilerplate is untouched!

This other GIF showcases the DX for compositions:
composition

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)

Collapse
 
protowalker profile image
Jackie Edwards

Just so you know: remix moved their examples to its own repository, so the links to the jokes demo aren't working.

Collapse
 
gugaguichard profile image
Gustavo Guichard (Guga)

Thanks for pointing it out @protowalker !
I'm updating it right now

Collapse
 
matiasleidemer profile image
Matias H. Leidemer

Great write up, Gustavo! Iโ€™m looking forward for taking remix-domains for a spin!

Collapse
 
gugaguichard profile image
Gustavo Guichard (Guga)

๐Ÿš€ Great!! Let us know how it goes!!

Collapse
 
timwaddell profile image
Tim Waddell

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?

Collapse
 
diogob profile image
Diogo Biazus • Edited

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

type ErrorInstrumentation = <T>(result : Promise<Result<T>>) => Promise<Result<T>>
Enter fullscreen mode Exit fullscreen mode

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.