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?”
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 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 (
That means that we turn this:
defp http_client() Application.get_env(:my_app, :http_client) end
@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!
The parts to highlight are:
Application.compile_env! for compile-time checks in Elixir can be a powerful tool for enhancing the decoupling of your application components.
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 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!