Skip to content

Learning Elixir and Ecto

Tags: development, elixir, learning • Categories: Learning

Table of Contents

I’m finally posting a long-running learning document that I wrote as I I continued work on my original Elixir side project. I stopped working on this for at least a year and recently picked it back up as part of exploring some technologies for my next startup.

This post got way longer than I expected, but hopefully, it’s a great compendium of notes and learnings from someone trying to learn Elixir who has a strong understanding of ruby, python, javascript, etc.

What I’m learning

Here’s what I’m going to be learning:

  • How does Ecto work?
  • Supervisor, tasks, processes, etc.
  • "Let it Crash" philosophy. What exactly does this mean in practice?
  • Macros/metaprogramming
  • Testing

    Persistence in Elixir using Ecto

  • Ecto was split into ecto and ecto_sql a couple of versions back. All of the DB stuff (adapters, migrations, etc) is in the ecto_sql package. This is a recent-ish change: old articles assume that you are using the older packages and you won’t be able to find the migration docs in the ecto docs.
  • I want to store lat, long, and a JSON payload. I don’t need to query against the payload, but I need to query against lat/long. What field types should I use?

    • Jsonb is the best column type for storing JSON in Postgres. In your ecto schema file, you can use field :payload, {:array, :map} to define a jsonb which is an array of objects/maps. In your migration, you’ll want to use add :payload, :map
    • I decided to use PostGIS for my app, although it’s most likely overkill. In your schema, use field :coordinates, Geo.PostGIS.Geometry and %Geo.Point{coordinates: {lng, lat}, srid: 4326} to generate the point to insert into your DB. Unfortunately, you need to manually run some SQL in order to setup the field in your migration: execute("SELECT AddGeometryColumn ('table_name','coordinates',4326,'POINT',2);")
  • Ecto has a different mental model than ActiveRecord. Associations and model schema is specified in the "schema" block of the module with use Ecto.Schema and specified in the database migrations (priv/repo/migrations/*). Fields are not automatically sourced from the table data like ActiveRecord, you need to explicitly tell Ecto what fields exist on the model.
  • There’s a bunch of generators available through mix phx.gen.*. If you want to generate a migration & schema, without the view layer: mix phx.gen.schema DataCache data_cache lat:float lng:float payload:map
  • Model |> first() |> Repo.one() is similar to #first in rails.
  • Use Repo.get(ExampleModule, 1) for ExampleModel.get(1) in rails.
  • Geo.PostGIS.Geometry needs to be used instead of Geo.Point. A fix is outlined in this issue. Here’s another good post on postgres GIS stuff.
  • Ecto.Query is confusing as well. It looks like there is a ton of metaprogramming/macro magic that happens when you import Ecto.Query that lets you ‘naturally’ write queries without too much special syntax on top. It feels weird at first, and I’m still not sure how the macros work, but I think I like it.
  • embedded_schema and embeds_* allow you to embed documents within a database insertion and validate a specific document schema. I could see this being super useful, surprised more ORMs don’t adopt this. Would be neat if this could tie into the postgres json schema validation extension.
  • The order in a field definition’s tuple matters. field :payload, {:array, :map} is an "array of maps" vs field :payload, {:map, :array} is a "map of arrays" (this isn’t really possible). Helpful if you are backing payload with a jsonb postgres column.
  • If you want a field to accept an array or a map, you need to define a custom type. This seems a bit crazy to me: pretty heavyweight for something that should be really simple.
  • Undo a migration and then rerun it: mix ecto.rollback -n 1 && mix ecto.migrate
  • Streaming is really neat. Ecto.Repo(fn -> Ecto.Repo.stream |> Task.async_stream |> Stream.run end, timeout: :infinity) is the magic incantation you are looking for to run an operation across multiple processes. By default, the number of cores available on your machine is used to determine the number of concurrent processes to execute. Super easy to setup parallel processing that still feels functional. So beautiful.
  • Schemas are designed to be distinct from contexts, which expose an interface for the rest of your app to interface with the schema/data layer. The idea being that the schema module is only concerned with generating changesets and defining fields. A context is a view layer for your data layer.
  • You can run raw SQL via Ecto.Adapters.SQL.query!which returns an array of an array (looks like a CSV). One big limitation here is you only run a single statement. Here’s how to run multi-statement SQL in Elixir.
  • The ecto struct/schema does not allow you to access fields via [], although it’s just a map under the hood, which makes accessing field values frustrating sometimes. Map.fetch! is a good alternative
  • When :numeric is defined in the migration schema, :decimal type must be used in the ecto schema.
  • There is not a built in way to log SQL, with all params in place. The best you can get, without plugging into ecto (or db_connection) telemetry events, is the SQL with a list of arguments in the log line. This is because the Ecto library actually sends the parameterized SQL to Postgres (interesting that Postgres supports parameterized queries out of the box, what an amazing database)

  • It’s not recommended to decorate your schemas with generated attributes. You should use contexts instead.
  • field :id, :integer is auto-added to all schemas which is why you’ll get an error when trying to add an id field explicitly to Elixir schemas.

Core Language Concepts

  • The "let it crash" philosophy is confusing. I think the idea is more to build deterministic code paths and create high-level retry strategies so you don’t need to clutter your code with try/catch blocks, although I’ve yet to really see examples of this in practice.
  • Changelog (the app) has a really great example of using a custom schema module. This enables you to add a bunch of methods that will be inherited by all schema modules.
  • ~e allows you to ‘cast’ a heredoc string as a eex template. These custom sigils for heredocs are a really neat feature of the language. Surprised other languages haven’t copied this.
  • You can pass a block directly to a method (ruby-style), but it’s executed immediately (doesn’t wait for a yield). Add do: block to your function argument def test(do: block). block will contain the result of the block and can not be yielded repeatedly.
  • There is a built-in for checking the existence of an object in an array action in [:edit, :update, :delete]
  • You can copy code into a production iex to test code live in your production environment. Dangerous, but awesome. I’m a big fan of languages providing dynamic runtime access in production, it’s a huge lever for high dev velocity.
  • You can use matches in a conditional, but it’s not recommended. if {:ok, nil} == {:ok, [] |> List.first}
  • Specifying a default parameter uses \\ i.e. javascriptFunction(opt = default) => elixirFunction(opt \\ default). If you are defining a method multiple times, define the default in the first definition of a function and then it will flow through automatically to the other function definitions. This is a bit hard to reason about.
  • You can define variables that are accessible to all functions within a module. These are called module attributes. However, by default, they are only used at compile-time and cannot be accessed dynamically (in a repl for instance). I’m guessing these are inlined by the compiler.
  • Keyword tuples are special: [value: 'default'] == [{value: 'default'}]. You can omit the [] when it’s the last option of a function call. Ordering matters on these for pattern matching, so don’t pattern match on keyword tuples unless you have to.
    • Relatedly, the order of keywords matters if you define them explicitly in the function signature.
  • One of my favorite ERB patterns isn’t possible in elixir: <%= some_content_function if condition %>
  • All of the config is essentially a massive hash that can be inspected at runtime Application.get_env(:logger, :console), or for all of the configuration IO.inspect(Application.get_all_env(:app))
  • There are so many shared concepts from rails. It was very smart for the Elixir team to do this. %w, Kernel, symbols/atoms, etc.
  • erl -eval 'erlang:display(erlang:system_info(otp_release)), halt().' -noshell gets the OTP version installed on the system.
  • <> concats strings instead of + in most languages
  • Elixir takes lexical scopes really seriously. Rebinding a variable in an if ... do ... end block does NOT reassign the variable in the function scope. Instead, you need to thevar = if ..., do: .... This makes sense given how Elixir treats variables (pattern matching vs assignment) but is frustrating regardless.
  • I’m going to need a plug to copy the IP address of the client through the nginx proxy so my GeoIP stuff works. Let’s learn plug!
    • Super simple middleware stack. You define a set of transformations that conform to a very simple protocol.
    • Routing plug options are defined like this.
    • I wanted to test the plug I was using with the latest plug. To update plug in a package: mix deps.update plug
    • Plugs are added via endpoint.ex. Specific "functions" of plugs are called by defining a pipeline in your router.
    • Here’s how to compose plugs
  • All of the ~ tricks (~w(word), w(atom)a, etc) are called Sigils
  • Hash#dig in ruby-land == Map.get_in
  • Phoenix routing can’t do optional URL parameters easily. Surprised that this isn’t supported.
  • If you have an atom-keyed map you can use the dot syntax to access them map.key. Atoms aren’t garbage collected, don’t use them for external data. Use atoms kind of like constants.
  • You can add filters on array iterators (for i <- list) similar to what you might do with a arr.filter { |i| ... }.each { |i| ... } in ruby: for i <- list, i > 2, do: i
  • Map update shorthand: %{expenses | groceries: 150, commute: 75}
  • "Erlang allows only one assignment per variable name (SSA-like) the ‘Elixir variables’ will have non-meaningful names in Erlang code." ref
  • Single-quote character-list strings are included for compatibility with Erlang. In most cases, you don’t need or want to use them. You can use ~s if you want a binary list (double quote) string
  • Elixir has the ability to create strong boundaries between applications using Umbrella apps.
  • There’s not a clean way to conditionally add logic to a pipeline. I get the logic here: you should pull that logic into a separate method, but there are cases (i.e. ecto queries) where it would be easier to read to do something like |> where(field: table.field != ^var) if condition. https://stackoverflow.com/questions/46961244/how-do-i-pipeline-an-if
  • Opus is an interesting take on developing ‘business logic stacks’ isolated in a module.
  • You can call a function reference with .(), for example: Module.function/0.()
  • You can construct lambda functions with multiple definitions/guards.
  • If you are like me, you installed elixir & erlang with brew to start and then moved to asdf. You’ll want to make sure you uninstall erlang and elixir from brew and specify both the erlang and elixir versions in your .tool-versions file.
  • Since if is more cumbersome compared to ruby, condition && Thing.to_execute() is a great way to guard code with a conditional.
  • Hackney is an underlying erlang http library that is used by most HTTP abstractions :hackney_pool.get_stats :default
  • You can use =~ to check if a string contains a substring. The right hand side doesn’t need to use a regex, but it can.
    • html_response(conn, 200) =~ "Site Title"
    • "hi ho here we go" =~ ~r/ho.*/
  • You can use the "vertical bar" operator to destructure a list. [ first_element | remainder_elements ] = [1, 2, 3]
  • self is not a reference to the module or object (remember, we are in functional world). It’s a reference to the current process.
  • Updating a map with a string key %{some_json | "url" => "https://www.1/ofm/cic/"}
  • Elixir 1.15 makes it more challenging to check if an optional package is loaded from the parent application. prune_code_paths: false fixes this, but you lose all of the benefits of the Elixir 1.15 compiler. You can run mix deps.compile --force once which fixes the issue as well.
  • You have to add additional flags to your environment to get extended help support in ElixirLS. Here’s how I did this.
  • If you modify source code in deps/ it will not automatically recompile. It looks like changes to dependencies are not automatically picked up.
  • If you reference a module that does not exist, you will not get an error. Even if you use Dialyzer, you will not get a notification about an invalid module. You will get a notification/warning about an invalid method reference, so this is really only problematic when dealing with config (or other areas where you reference a module and it’s not called).
  • When destructuring a named struct, you have to use the name/type of the struct %Geocoder.Coords{lat: lat, lon: lon} = coord
  • There is not a way to call private defp methods. You used to be able to use apply, but this doesn’t work for private methods anymore.
  • There’s some nice utilities for comparing verions Version.compare(System.version(), "1.15.0") in [:eq, :gt]
  • It looks like stack traces get lost or mutated in some way. Similar to how async/await breaks stack traces in javascript / node.
    • This is a core feature of the underlying BEAM VM. There’s not a way for Elixir to hack around this. It makes debugging much more challenging, especially in more complex systems, since you can’t identify where an issue originated from.
  • You can do some really interesting data manipulation tricks:
    metadata = [:boom]
    meta = %{boom: "hi"}
    for key <- metadata, value = meta[key], do: {key, value}, into: %{}

Hacking & Debugging

  • Use IEx.started? to determine if we are in an IEx session. However, in production IEx.started? returns false in prod.exs + runtime.exs when started via iex -S mix
  • Use Module.module_info to inspect a module in the REPL (like methods in ruby). There’s also Module.__info__(:functions)
  • Want to debug a mix task? iex -S mix app.task --trace
  • When opening up a pry session within a task or DB transaction it’ll timeout and cancel the session. This makes it much harder to debug things. One of the things I hate about Javascript and love about ruby + python.
    • You can use --trace and timeout: :infinite to get around this, but it takes modifying the source in most cases, which makes debugging much more cumbersome.
  • It doesn’t look like it’s possible to break automatically on uncaught exceptions. I love this feature from Javascript/Ruby, it’s a shame that Elixir doesn’t support this.
  • You can define default iex config in a local .iex.exs or ~/. iex.exs. Here’s mine
  • If you want to execute multi-line pipelines in a REPL use parens around the code
  • respawn gets you out of a pry session
  • I couldn’t get the graphical debugger in VS code working. I was able to get the erlang debugger started via :debugger.start(), but wasn’t able to pick a function to break on. Tooling needs a lot of improvement here; IEx.pry() seems like the easiest way to debug.
  • IO.inspect returns the first input arg, which makes it really easy to throw this in your pipeline to output a var part-way through the pipeline.
  • Ctrl-\ exits an iex shell like ctrl-c in most other repls
  • You can enable shell history for iex, but it’s not a per-project history, it’s global. export ERL_AFLAGS="-kernel shell_history enabled"
  • Some pry tips
  • IEx.Helpers contains a bunch of great helpers
    • binding() exposes local variables
  • The best option you have is setting breakpoints. You can easily do this programmatically, which is neat.
  • Exceptions aren’t just {:error, ...} tuples. You have to use a try block to catch them. For some reason I figured exceptions got transformed into error tuples and you could use the standard Elixir control flow to manage them.

Metaprogramming

My favorite thing about ruby is its metaprogramming.

Is it dangerous? Absolutely. Is it powerful? Yes. Power is helpful when you are moving fast, even if it’s dangerous. Strong metaprogramming enables you to debug applications quickly and hack in changes in a flash.

Here’s how Elixir’s metaprogramming works:

  • Much of the Elixir language is a macro. if, def, etc are essentially just macros. The idea is, you can modify and extend the core language. When you think of it in this way, Elixir is a Lisp.
  • Metaprogramming happens at compile time, which means that it’s possible for some code to not be included in your application build. Ex: if logging is disabled, the logging statements could just not exist in your compiled binary. This is important if you are using Elixir releases for deployment: the ENV that is present when the application is compiled could change the code.
  • All metaprogramming is done via quote, unquote, and defmacro.
  • use triggers the __using__ macro on the module that’s passed in. Kind of like includes in ruby.
  • __MODULE__ is an atom of the module’s name.
  • There’s no monkeypatching. You can’t reach into arbitrary modules and classes and modify them at will.
  • However, you can override/patch a module by copy/pasting the entire module definition, modifying it, and then including that file in your application’s source. It’s unclear to me exactly how the load order works, but this is a neat trick when you are in a pinch.

Applications, GenServer, and Processes

  • You can think of processes in Elixir as ‘free’ threads. You don’t need to worry about creating too many of them. They are extremely efficient.
  • The magic of the GenServer is you get the await for free. Since everything is immutable by default, this makes debugging individual errors much cleaner: you can copy the args passed in, extract into a test case, create a fix, and move on. This flow is a great argument for building functional-by-default in any programming language.
  • I was wondering why some packages used a process ‘manager’ like poolboy instead of the built-in GenServer.
    • GenServer gives you fault tolerance (will restart your processes when they fail), but does not allow you to define a worker pool.
    • Worker pools are helpful if you are communicating with an external system (like postgres or redis) that has limits on the number of connections allowed concurrently. An interesting aspect of the Erlang architecture is there aren’t different (traditional unix) processes running (worker, web, scheduler, etc). You don’t need to ‘budget’ connections to external services between them—you can have a single worker pool that services all aspects of your application.
  • If processes are nearly free, and you don’t need to manage an external resource, why would you use a GenServer? If you need to manage state. For instance, if you are writing to an in-memory cache using ETS
  • Get all running applications in app Application.started_applications()
  • You can stop & start each individual application Application.stop(:geocoder) and Application.start(:geocoder)
  • It looks like use Application magically generates a Supervisor module within the module use is declared. For instance: Supervisor.which_children(Geocoder.Supervisor)
  • When GenServer.call is executed handle_call on the module (which must have use GenServer ) is called.
  • If your process contains some state that you want to clear before each test run you can do something like:
    setup do
    :ok = Supervisor.terminate_child(Geocoder.Supervisor, Geocoder.Store)
    {:ok, _} = Supervisor.restart_child(Geocoder.Supervisor, Geocoder.Store)
    :ok
    end
  • If you want to remove a process completely from the supervisor tree (so it doesn’t get automatically restarted)
    IO.puts("Disabling quantum scheduler")
    Supervisor.terminate_child(App.Supervisor, App.Scheduler)
    Supervisor.delete_child(App.Supervisor, App.Scheduler)
  • The above snippet is super helpful in a .iex.exs file in your project to disable specific processes (like any cron-like operations, which is what is disabled in this case) when you launch an interactive iex session.

Logging

  • Looks like structured logging is built-in, but not enabled by default. You’ll need to modify all of your env config to get things working.
    • metadata: :all is required on the logger config. You can inspect the config in your current environment Application.get_env(:logger, :console).
    • You also need something like format: "$time $metadata[$level] $message\n". The metadata var is not included by default.
    • Modifying config on the fly via Application.put_env(:logger, :level, :debug) doesn’t work on the fly. You need to use Logger.configure(level: :debug) or Logger.configure_backend(:console, metadata: :all) (depending on what config you are trying to edit)
    • Exception reporters like sentry add a separate log backend that listen for error events with specific metadata (specifically crash_reason).
  • Not everything converts to a string by default. For instance, a map won’t automatically be formatted as a string when passing it to Logger.info.
    • This seems to have changed in recent Elixir versions.
  • You should use a lambda function as the input to your Logger call when it’s expensive to generate the method. However, with compile time elimination of specific log levels this isn’t as useful.
    • I personally don’t like the idea of compile time filtering of log messages. This makes it harder to interactively debug production.
  • It’s not easy to convert {kind, reason} from a catch statement to a crash_reason which will throw an error to Sentry (or another error tracker). Here’s the details and a workaround.

Custom Elixir Structured Logger

I’m a huge fan of structured logging, this one of the big devprod tips I took away from my time at Stripe. By adding consistent key=value pairs to your logs and avoiding string interpolation in your log message, you can easily search + filter logs and run analytics (I’m still trying to figure out the best local log analysis stack, send me your recommendations!).

Elixir almost comes with structured logging support out of the box, you just need a bit of customization

defmodule CustomLoggerFormatter do
  # change this depending on what default context you don't care about
  @exclude_keys [:domain, :pid, :file, :line, :node, :mfa, :application]
  @pattern Logger.Formatter.compile("$time [$level] $message: $metadata\n")

  def format(level, msg, timestamp, metadata) do
    metadata =
      metadata
      |> Keyword.drop(@exclude_keys)
      # convert all values to strings
      |> Enum.map(fn
        {k, v} when is_list(v) -> {k, inspect(v)}
        pair -> pair
      end)

    # Use the default Logger.Formatter.format/4 for the rest.
    Logger.Formatter.format(@pattern, level, msg, timestamp, metadata)
  end
end

Then, in config.exs:

config :logger, :console,
  format: {CustomLoggerFormatter, :format},
  metadata: :all

Package Management

Elixir is definitely much more modular than the ruby ecosystem. The stdlib is much smaller and you end up having to mix in a lot more little packages to handle common tasks (like URL parsing). Node is similar, mostly because so many packages are abandoned or nearly duplicates and it’s hard to determine which group of packages you should use. I’m finding I have the same frustration with the Elixir ecosystem, although there are fewer packages and it’s more obvious which packages are the canonical solution for various problems.

It’s nice how documentation for packages is hosted on a consistent site with a well-looking layout. This improves the developer experience quite a bit! I know Ruby has this (rdoc) but it’s bad so I never end up using it. Python does a similar thing with readthedocs.io, but it’s also poorly designed (no cmd+k jump, poor search, etc).

  • mix.lock isn’t as descriptive as bundle.lock (that identifies which gems require which version of a dependent gem). mix deps.tree is a really nice alternative to digging through the Gemfile.lock
  • Mix.install is an interesting feature that allows you to write one-off scripts and define dependencies on the top of the file. However, you cannot use this within a directory that has a mix project.
  • Dependencies are not removed from your lockfile by default. Use mix deps.clean --unlock --unused
  • If I got into weird trouble, here’s how I would wipe all local build data rm -rf _build .elixir_ls deps
  • Rebar is the Erlang package manager. I don’t fully understand how/when it’s used, but you’ll see it referenced a good bit.
  • Drift in the erlang version used to run Elixir code can cause weird differences between how packages operate on dev, prod, CI, etc; make sure to use consistent versions via asdf.
  • Unlike bundler and npm, you can’t override a package for local development with a CLI command. You need to modify your mix.exs, here’s the format I used to more easily switch between a hex-hosted, github-hosted, and locally-sourced elixir package:
defp defps do
    [
      {:geocoder, "~> 2.0.0",
       [
         # path: "~/Projects/elixir/geocoder",
         github: "iloveitaly/geocoder",
         branch: "default-worker-config"
       ]}
    ]
end

Mix Tasks

  • Mix tasks are like rake tasks. Create one via lib/mix/tasks/the_task.ex.
  • There seems to be a very consistent template for creating these, but there’s not a generator for it yet. Look at an example app and copy/paste a task.
  • Once you put the task in that directory it magically appears in mix help.
  • Mix tasks don’t work with releases. Mix was designed only for development tasks and there is no production task runner.
  • However, if you use a heroku-style deployment you can use mix tasks in production. I find it super helpful to have a friendly task runner which is one of the reasons I ended up not using releases for deployment.

Testing

  • mix test or mix test test/the_file_test.exs:20 to run a specific line or file. The test runner is built into the library. Nice!
  • What about fixtures? You can use heredocs (""") for embedding JSON in tests.
  • There’s a fixture library built for Ecto as well.
  • Mocks were not originally recommended, but now there’s a library for mocks available.
    • However, using mocks take quite a bit of work: you need to refactor your code to been nicely abstracted into to plug a mock into the right section of the stack. This is hugely problematic for my engineering style: using mocks to build unit tests for trick + error-prone code is really helpful. Not having this is a pretty large downside.
  • Database resets seem to be built into the Ecto framework. Not sure how they work (maybe they just a transaction?)
  • To [open a REPL](<https://adamdelong.com/iex-pry-test/
  • MIX_ENV=test mix ecto.reset>) inside of a test iex -S mix test --trace
  • I wish I could break on unhandled exceptions in tests. I love this functionality from rails.
  • Phoenix comes with a couple of different test helpers out of the box that are throw in support/

Linting & Editor Support

  • Elixir editor support is surprisingly great. The main two tools you want are Credo + Elixir LS.
  • Formatting is built into the language and Elixir LS exposes it to the editor really nicely.
  • exs files are not processed in the same way as ex files. Basic things like missing variables won’t be exposed in the editor for exs.

Here are the two extensions you want .vscode/extensions.json

{
  "recommendations": ["JakeBecker.elixir-ls", "pantajoe.vscode-elixir-credo"]
}

Here’s the settings.json that worked for me.

{
  "cSpell.enableFiletypes": [
    "elixir"
  ],
  "[elixir]": {
    "editor.formatOnSave": true,
    "editor.defaultFormatter": "JakeBecker.elixir-ls"
  },
  "emmet.includeLanguages": {
    "html-eex": "html"
  },
  "elixirLS.fetchDeps": true,
  "elixirLS.suggestSpecs": false,
}

Upgrading Elixir, Erlang, and Phoenix

  • I originally wrote this post ~2 years ago on Phoenix 1.5 and Elixir 1.2. I wanted to upgrade the application and see how much work it would take to get everything working.
  • The upgrade process wasn’t too bad and there were helpful error messages along the way.
  • There were great guides walking through the pheonix upgrade process.
  • The applications struct that was required in older versions can cause issues when upgrading.
  • This guide was great at moving to a bootstrap/scss-based compilation process with phoenix 1.7
  • My biggest issue was I missed an invalid module reference in my config that had me pulling my hair out for a long time. Remember that invalid module references in config will not error at all. Hopefully, the new gradual typing system that Elixir is working on will resolve these sort of issues.

Secret Management

  • config/ structure was updated in 1.11, older blog + forum posts will reference the old architecture (which assumes system environment variables are used at compile time).
  • prod.secrets.exs can be consolidated into a config_env() == :prod block inside config/runtime.exs which is always executed in the runtime. This allows you to use system environment variables within this config since it’s not executed
  • The config/ system is an Elixir thing, not a Phoenix thing. This is really nice: you can use this in any packages without Phoenix. Elixir does a great job managing configuration.
  • I like to use separate local databases for testing and development. Here’s the snippet in config/runtime.exs I used to implement this:
database_url =
  System.get_env(if config_env() == :test, do: "TEST_DATABASE_URL", else: "DATABASE_URL") ||
    raise """
    environment variable DATABASE_URL (or TEST_DATABASE_URL) is missing.
    For example: ecto://USER:PASS@HOST/DATABASE
    """

maybe_ipv6 = if System.get_env("ECTO_IPV6") in ~w(true 1), do: [:inet6], else: []

config :app, App.Repo,
  # ssl: true,
  url: database_url,
  pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10"),
  socket_options: maybe_ipv6

Here’s the docker-compose.yml I used (with orb!) to host databases locally:

version: '3.8'

services:
  postgres-gis:
    image: postgis/postgis:15-3.3
    container_name: app_postgres_gis_container
    environment:
      POSTGRES_DB: app_dev
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: postgres
    ports:
      - "5555:5432"
    volumes:
      - postgres-gis-data:/var/lib/postgresql/data

volumes:
  postgres-gis-data:

Here’s the .envrc (used with direnv) to automatically source the variables:

export PLUG_EDITOR=vscode://file/__FILE__:__LINE__
export DATABASE_URL=postgresql://postgres:postgres@localhost:5555/app_dev
export TEST_DATABASE_URL=postgresql://postgres:postgres@localhost:5555/app_test

dotenv_if_exists .env

(I’ll leave my secret configuration approach with .envrc and a separate .env to a separate post)

Open Questions

  • Callbacks (what does @behaviour do?). They are some sort of metaprogramming magic, but I never fully understood them.
  • Processes/GenServer/GenStage. Although I did work with packages that create their own processes, I didn’t work with Gen{Server,Stage} from scratch.
  • Clusters/Nodes (connecting multiple erland VMs together to load balance)
  • What are more advanced functional programming concepts? I’d like to dig into this in a deeper way.
  • Is there a style guide for Elixir?
  • What’s the best background job library?
  • What’s up with @spec?
  • Learn more about supervisor trees.
  • How do built in ETS tables work?
  • How and when is code actually loaded? :code.get_path
  • How does property testing work?
  • Is there a good capybara-like library for Elixr?
  • https://github.com/Adzz/ecto_morph and https://github.com/supabase/realtime look like interesting tools.
  • This is a really interesting grammar parsing library. I’d love to understand how to write my own language grammar + parser at some point. Seems interesting.

Resources for learning Elixir

General:

Specific Topics:

Metaprogramming:

Open Source Apps

Great to throw in a local folder for ripgreping: