Hey! I recorded two video courses!
If you like this article, you will also like the courses! Check them out here!
Hey folks, welcome back! After such a long time without a blog post from me, you might ask yourself:
When is a joke a dad joke?
When it's apparent.
Anyway, let’s get started with today’s topic: Why you shouldn’t kill your Phoenix context and - in fact - resurrect it!
🔗 A Retraction
In June 2023, I wrote a blog post titled Kill your Phoenix Context in which I proposed a new software design for Phoenix applications. In short, I suggested you delete the generated context
altogether and create dedicated repository
, finder
, and service
modules instead.
Repositories hold all CRUD functions for a schema, Finders encapsulate complex queries, and Services contain the business logic for one use case each (e.g. CreateUserService
). I based the suggestion on my experience of working for a company that used this design heavily, and it seemed to work pretty well for them. However, one year later, I changed my mind.
🔗 The Problem
The fundamental issue with the design above is that it is Module
-oriented rather than Function
-oriented. It tries to organize your code around modules instead of functions.
This is very much Object-Oriented Programming (OOP) thinking. In OOP, the Object
- and therefore its definition, the Class
- is the first-class citizen. Classes are the fundamental building blocks of your software design. Functions are just “children” of classes and don’t play a role in your design. That’s why you would design your system around classes, not functions, in OOP.
In Functional Programming (FP), the Function is the first-class citizen. You can use functions without having to wrap them in classes. In fact, Elixir doesn’t even have classes! It has modules which look very similar to classes, but they don’t define state or behaviours like classes do.
Modules look so much like classes that they are often mistaken, especially by folks who have an OOP background. But if you want to design software in Elixir, you must understand one thing:
Modules are just collections of functions. Nothing more.
🔗 The Proposal
Personally, I moved away from the module-based approach above and use different designs for my projects now, depending on how complex I expect them to become. They all include Phoenix Contexts though.
For small projects, I use the generated code structure as-is. I try to keep my controllers and LiveViews thin and move all my business logic into the contexts. In small projects, your “real” business logic only involves a few functions anyway, and the rest of your system will be basic CRUD logic.
For medium to large projects, I move all CRUD functions from the contexts to dedicated repositories
and keep only the business logic in my context. I still use finders
, but don’t use services
anymore. The advantage of this approach is that I remove the “noise” from my contexts (the CRUD stuff) and only keep the most important functions in there. I do this because I’ve seen large projects which combine CRUD and business logic in one huge context, and it’s hard to find the important parts among all the get_X_by_Y
helper functions.
🔗 An Example
Let’s walk through an example to make this design more tangible. Imagine you have a system that handles invoices. You have your usual CRUD logic, but also a bit of complex business logic like finalize invoice
that requires a few more steps than just updating an invoice.
After generating the code with mix phx.gen.live Invoices Invoice invoices ...
, the first thing I do - but only for medium to larger projects - is to move all CRUD logic from the App.Invoices
context to an App.Invoices.InvoiceRepo
repository.
I allow my AppWeb
domain to call the repository directly. So, the AppWeb.InvoiceLive.Index
LiveView can call App.Invoices.InvoiceRepo.list_invoices()
and doesn’t have to go through App.Invoices
. However, if my project becomes very complex, I’d consider restricting direct access to my InvoiceRepo
and rather require other subdomains to call the invoice repository through App.Invoices
. That way, I can easily transform my context into a “public interface” for the rest of my app and control which functions they have access to.
Next, I add the business logic to my App.Invoices
context like finalize invoice
. My context is allowed to call other contexts and everything they “own”. I even allow calls to my AppWeb
domain, but only for small things like generating a URL to send in an Email and only if the function call doesn’t originate in the Web domain. If it does, I require the caller to pass a URL or a function for generating the URL. You can see an example of this in the ResetPassword
LiveView that mix phx.gen.auth
generates for you. I try to limit the calls from my App
to my AppWeb
domain as much as possible though, and 9-out-of-10 times, I can rewrite my function to receive a URL parameter instead of having to generate it itself.
And that’s it really! It’s a simple design which most Elixir developers will understand quickly, but it leaves room for refinement if your codebase grows significantly.
Let me know if you’d like me to record a video about this and I’ll happily put it on YouTube. Otherwise, you can always join my livestream and ask questions :)
🔗 Conclusion
And that’s it! I hope you enjoyed this article! If you want to support me, you can buy my book or video courses (one and two). Follow me on BlueSky or subscribe to my newsletter below if you want to get notified when I publish the next blog post. Until next time! Cheerio 👋