We can't find the internet
Attempting to reconnect
Something went wrong!
Hang in there while we get back on track
Dodging Runtime Exceptions with Compile Time Config
Picture this: You’ve implemented all the advice in Mocks and Explicit Contracts and your system’s behavior has been decoupled from its implementation. You fire up your app with mix phx.server
, lean back in your chair, and then… BAM! A runtime exception hits you right in the face:
** (UndefinedFunctionError) function MyApp.HttpClient.get/1 is undefined or private
MyApp.HttpClient.get("https://api.example.com")
(my_app 0.1.0) lib/my_app/my_module.ex:10: MyApp.MyModule.my_function/1
Your heart sinks. Your flow is disrupted. You’re left wondering, “How did this happen? How can I avoid this in the future?”
That’s where Application.compile_env!
comes into play. Making this small change will allow you to get your compile-time warnings and errors back while maintaining the ability to use different service implementations in different environments. Let’s dive into how this works.
The Goal: Decoupled Systems
A well designed system separates the responsibilities and dependencies between its different parts. This separation lets each part do its own thing, making the system more flexible and easier to maintain.
In Elixir, this is generally accomplished with Application.get_env
and a private function, like this:
def get_resource(url) do
http_client().get(url)
end
defp http_client()
Application.get_env(:my_app, :http_client)
end
Now the system has the ability to swap out HTTP client implementations. There’s typically a mock in test, a simple/in-memory client in dev, and the real thing in prod.
The Problem: We Lose Compiler Support
When we move from a concrete dependency, like this:
def get_resource(url) do
MyHttpClient.get(url)
end
To a runtime config approach, like this:
def get_resource(url)
http_client().i_dont_exist()
end
We lose all compiler support.
With a concrete dependency, if we try to invoke MyHttpClient.i_dont_exist()
, our app won’t compile. The compiler will rightfully complain that the function is undefined. However, the runtime config approach will compile and run just fine (until you actually try to call the function, that is):
Now, I don’t know about you, but I loathe runtime exceptions. They are endlessly jarring, annoying, and frustrating.
So, how do we achieve proper decoupling while keeping our precious compile time checks in place?
The Solution: Application.compile_env!
ElixirForum user Benwilson512 opened my eyes to this approach in a recent discussion on ElixirForum surrounding testing with mocks.
The approach is largely similar to the one taken with put_env
, with two key differences:
- Use a module attribute instead of a function
-
Specify implementations in compile time config (
config/(dev|prod|test).exs
) instead of runtime config (config/runtime.exs
)
That means that we turn this:
defp http_client()
Application.get_env(:my_app, :http_client)
end
Into this:
@http_client Application.compile_env!(:my_app, :http_client)
And explicitly specify each piece in compile time config:
# config/dev.exs
config :my_app, :http_client, InMemoryHttpClient
# config/prod.exs
config :my_app, :http_client, TheRealHttpClient
# config/test.exs
config :my_app, :http_client, HttpClientMock
As a result, your call sites use the module attribute and maintain private goodness.
def get_resource(url) do
@http_client.get(url)
end
This works because Elixir inlines module attributes during compilation. This means that, to Elixir, the function call is using a concrete module.
We now have all the benefits of a decoupled system without sacrificing compiler error messages.
As an added bonus, this also re-enables auto-completion and syntax highlighting!
Example Repo
Benwilson512 put together a repo demonstrating this approach by mocking the IO
module. Go ahead - pull it down for yourself and try to invoke a bad callback - the project will not compile.
The parts to highlight are:
Wrapping Up
Using Application.compile_env!
for compile-time checks in Elixir can be a powerful tool for enhancing the decoupling of your application components.
While Application.get_env
also supports decoupling, Application.compile_env!
provides an extra level of assurance that your code is correctly defined and implemented. By using compile-time checks, you can interact with your components as if they were the real implementations, making your code more flexible and maintainable.
So, next time you’re in the zone, don’t let a runtime exception ruin your flow. Use Application.compile_env!
and keep runtime exceptions at bay.
Special Thanks
Special thanks to Benwilson512 who showed me this technique and for putting together the example repo. This post would not be possible without him!
Build an AI Powered Instagram Clone with LiveView is on sale now!