While we tease Ran a lot about his crazy design patterns, they do have some merit to them and I think we should embrace some ideas. Specifically, the idea of defining interfaces, and then our business logic uses those interfaces. If you are familiar with the Design Patterns book, then the pattern that best fits this approach is the Strategy Pattern. You can find a good video of it here.
This repo uses the example of subscribing a user to an email newsletter. Suppose there is some frontend with two inputs, one for the users name and one for the users email, and then a button to send a request an api endpoint powered by AWS APIGateway and Lambda Integrations (much like our core services). The entrypoint for the lambda function that is triggered is in services/subscribeUser/handler.ts
. It parses the request body using zod, and if it fails returns a badRequest
. If it succeeds it calls a function called subscribeUser
with the email and name, as well as getUserByEmail
, insertUser
and sendEmail
. Then it handles errors thrown by subscribeUser
and returns the appropriate APIGateway result.
Inside of subscribeUser
, it calls getUserByEmail
to check for a user that already has that email, calls insertUser
with the passed in email and name and a uuid, and it calls sendEmail
with the appropriate arguments. This is where our business logic
happens.
Notice how in subscribeUser
we do not directly import functions for getUserByEmail
, insertUser
and sendEmail
. Instead they are defined in the parameters of the function in the ctx
object as types GetUserByEmail
, InsertUserFn
and SendEmailFn
, and utilize dependency injection. Now, why do this rather than just importing the functions at the top and using them? There are two reasons.
The first is that by defining an interface for these functions and making the caller of subscribeUser
pass them in, we are forcing a strict contract of what subscribeUser
needs in order to work, and it is up to the caller to implement a function that adheres to that contract. The implementation can change without the business logic needing to change. For example if we switch from SES so SendGrid in this example.
The second is that this is way more testable. If we were to just import and use the implementation of insertUser
and sendEmail
we would have to test by and creating spys and mocks of these files. We can simply create a fake function that returns some fake data that adheres to the interface and pass it in. See services/functions/subscribeUser/subscribe-user.test.ts
for an example.
If we look at the structure of the code as a diagram it looks like this.
APIGateway is the the entrypoint or outer most layer to our application. insertUser
and sendEmail
are implemenations of interfaces to save a user to the database and to send an email. Lastly, subscribeUser
is our business logic that uses getUserByEmail
, insertUser
and sendEmail
to complete the applications. The role of the APIGateway Lambda handler is to process the lambda event, setup the dependencies of subscribeUser
, call subscribeUser
, handle errors from subscribeUser
, and return the appropriate result. Now if we switch to express.js on a service instead of Lambda, we cany copy subscribeUser
and all we have to change is how we process the express.js request object and return the correct express.js response. Then getUserByEmail
, insertUser
and sendEmail
implement interfaces using whatever services necessary to persist a user and send an email. The point of all of this is now subscribeUser
can work without any knowledge of what framework we are using to build an API (could be APIGateway and lambda, express whatever), what database we are using (could be MySQL, MongoDB whatever), and what we are using to send emails (could be SES, SendGrid whatever). All those things can be changed without changing our business logic.
If you look at our services, you'll notice we usually make a __tests__
folder for each service that mirrors the structure of the source code. This is fine but to me I think there is a better way. The purpose of unit tests is to test the smallest possible unit of code. So to me it makes sense to put the unit test right next to the unit of code it is supposed to be testing. If you look at services/functions/subscribeUser/subscribe-user.test.ts
, you'll see it's right next to the file it is testing, services/functions/subscribeUser/subscribe-user.ts
. I think this will clean up our tests a decent bit because we won't have super long import statements, and tests are right next to the code they test.
I hope this repo demonstrates the vision I have for how our code should be structured in the future. It's testable by utilizing dependency injection and inversion of control. It's flexible by extracting 3rd party dependencies and services away from our business logic. And lastly it's simple, by not over complicating things with abstract classes, inheritance etc. At the end of the day everything is just functions.