ecto delete_all ecto repo delete_all ecto- elixir transaction data structure example database duplicate key new user elixir structs unique index

How database transactions work in Ecto and why Elixir makes it awesome?

Today we're going to look at how Ecto, Elixir's first-choice database access library, handles relational (SQL) database transactions.

Table of contents

    All followed with examples in our GitHub repository.

    This is a thorough guide for SQL experts and newbies alike, so let's summarize what we'll talk about in this article:

    • We'll explain what the purpose and characteristics of transactions are
    • Then, we'll outline how to approach them in Elixir, using "the Ecto way"
    • Finally, we'll get to explain how it feels superior to different ORMs

    However obscure it might sound, the combination of Elixir's functional character and other traits make it so easy to translate business requirements to algorithms powering excellent apps - and, with this guide, we're going to show you part of the reason why.

    What is a database transaction?

    If you're familiar with SQL databases, feel free to skip this section. Otherwise, let's delve a bit deeper into the mechanisms of database services.

    Consider a bank transfer, consisting of subtracting money from account A and then adding to B. It is mandatory that both operations are performed inseparably, because no money can be lost.

    Since our systems are capable of handling multiple operations concurrently, we don't want any agent to read either account's balance before the transfer is completed. We require that it not be possible to transfer $100 from A when its balance is only $50 (surprise, eh?).

    On top of that, we obviously want all of this to hold true even when our system crashes.

    What does it mean in technical terms? It means that our database is ACID. This slightly scary term is just an acronym for: Atomicity, Consistency, Isolation & Durability.

    • Atomicity. A transaction being atomic means that each of its steps is inseparable from the others. When any step fails or the application decides so, the database will be rolled back to the state before transaction started.
    • Consistency. The database must be consistent at all times, i.e. all integrity constraints must always be met. This is related to atomicity, because any transaction abort must transition the database to a previously consistent
    • Isolation - core to how databases handle concurrency, transaction A's intermediate steps should not be visible to other transactions before transaction A is committed. (Though actually, that's a simplification. Read up if you're interested in the full story).
    • Durability - the database must be crash-resilient, which means all data committed before a crash must still be seen as committed after the system is up again.

    Ruby v Elixir - Executing a transaction

    In object-oriented languages object-relational mapping libraries dealt with managing database connection, constructing queries and mapping the results to _objects_. So in Java there is Hibernate, .NET has its Entity Framework, and Ruby has ActiveRecord.

    Elixir's different because it's free from OOP's heavy, bloated objects that always carry a lot of internal state information. It relies on simple data structures such as maps to carry all needed information and metadata. So while Ecto is kind of the equivalent to ActiveRecord et al., it's not exactly an object-relational mapper because there are no "objects" in Elixir.

    It hardly matters, though. What matters for us is that these libraries talk to the database to execute operations and transactions, which are committed or rolled back dependent on the app's business logic.

    So in Ruby's beloved (well, really?) ActiveRecord you had this:

    transfer = 50
    
    ActiveRecord::Base.transaction do
      acc_a, acc_b = Account.find([1, 2])
      raise ActiveRecord::Rollback, :balance_too_low if acc_a.balance < transfer
      acc_a.balance -= transfer
      acc_b.balance += transfer
      acc_a.save!
      acc_b.save!
      [acc_a, acc_b]
    end
    
    => [<Account:...>, <Account:...>]

    The Ecto equivalent would be this. Not much of a win yet, but if you want a literal translation, here it is. Test it out by running iex -S mix run priv/script1.exs from our repository.

    import Ecto.Query
    alias TransactApp.Bank.Account
    alias TransactApp.Repo
    
    transfer = 50
    result = Repo.transaction(fn ->
      [acc_a, acc_b] = from(acc in Account, where: acc.id in [1, 2]) |> Repo.all()
      if Decimal.compare(acc_a.balance, transfer) == :lt, do: Repo.rollback(:balance_too_low)
      update1 = acc_a |> Account.changeset(%{balance: acc_a.balance - 50}) |> Repo.update!()
      update2 = acc_b |> Account.changeset(%{balance: acc_b.balance + 50}) |> Repo.update!()
      [update1, update2]
    end)
    
    => {:ok, [%Account{...}, %Account{...}]}

    There is an interesting difference, though, in the result of a rollback operation that might occur here:

    • ActiveRecord will return nil even though we passed :balance_to_low as the exception's argument. This would often lead to trouble debugging because the real reason the transaction was rolled back wasn't easy to track.
    • Ecto will return {:error, :balance_too_low}. This allows for easy pattern matching and tracking down what caused the rollback.

    We said it's not much of a win yet, but read further to learn about Ecto's idiomatic, functional ways of managing and composing transactional pipelines.

    Organizing transactional code with Ecto.Multi

    Procedural code, as seen in examples above, is heavy on assigning data to variables and usually there is an assumption that the whole procedure succeeds, and when it doesn't, most languages rely on an exception handling mechanism to handle errors. Elixir can do that too, but it leads to writing solutions with coarse-grained error handling or even to ignoring errors, which is even worse.

    I can't count how many times I've seen folks (myself included...) trying to update a record inside a transaction, which then failed because of validation errors, but the transaction kept executing, and there was an assumption that the update did succeed.

    Elixir's pipe operator and its pattern matching capabilities mean that it's super easy to compose transactional code as pipelines of functions. This leads to managing transactional code with better control and confidence.

    Here's an example code you could write as an Elixir script - it's also available in our repository:

    import Ecto.Query
    alias Ecto.Multi
    alias TransactApp.Bank.Account
    
    transfer_amount = 50
    
    retrieve_accounts = fn repo, _ ->
      case from(acc in Account, where: acc.id in [1, 2]) |> repo.all() do
        [acc_a, acc_b] -> {:ok, {acc_a, acc_b}}
        _ -> {:error, :account_not_found}
      end
    end
    
    verify_balances = fn _repo, %{retrieve_accounts_step: {acc_a, acc_b}} ->
      # we don't do anything to account B, but we could
      if Decimal.compare(acc_a.balance, transfer_amount) == :lt,
        do: {:error, :balance_too_low},
        else: {:ok, {acc_a, acc_b, transfer_amount}}
      end
    
    subtract_from_a = fn repo, %{verify_balances_step: {acc_a, _, verified_amount}} ->
      # repo.update will return {:ok, %Account{...}} or {:error, #Ecto.Changeset<...>}
      # {:ok, value} or {:error, value} is what these functions are expected to return.
      acc_a
      |> Account.changeset(%{balance: acc_a.balance - verified_amount})
      |> repo.update()
    end
    
    add_to_b = fn repo, %{verify_balances_step: {_, acc_b, verified_amount}} ->
      acc_b
      |> Account.changeset(%{balance: acc_b.balance + verified_amount})
      |> repo.update()
    end
    
    batch =
      Multi.new()
      |> Multi.run(:retrieve_accounts_step, retrieve_accounts)
      |> Multi.run(:verify_balances_step, verify_balances)
      |> Multi.run(:subtract_from_a_step, subtract_from_a)
      |> Multi.run(:add_to_b_step, add_to_b)

    batch actually encapsulates a sequence of functions that represent single steps of our pipeline. This means we actually haven't executed anything in the DB yet, but we have a recipe that we can build, reuse and run whenever we want to.

    Every function adheres to the same contract:

    • Return {:ok, value},
    • Expect as arguments: the current Repo, and a map.

    Then, a particular function will also pattern match the map to verify that a certain precondition has been met. For instance, if we mistakenly try to use the subtract_from_a function without having a chance to insert the returning value of verify_balances_step into our context, pattern matching will fail.

    Finding a way to have these batches well-organized is just up to you. One approach you might use is a service module pattern. In Phoenix, the recommended way to encapsulate data access and validations is contexts (our Curiosum approach to them is extending them with subcontexts and services - you can watch our Elixir Meetup #1 recording to learn more.

    The service module layer encapsulates an action that spans across multiple contexts, and using Ecto Multi for this purpose is very natural.

    First, let's define a module defining Ecto.Multi batches related to the Bank context (also available in GitHub):

    defmodule TransactApp.Bank.Batches do
      # Ecto.Multi batch definitions for the Bank context
    
      alias Ecto.Multi
      alias TransactApp.Bank.Account
      alias TransactApp.Bank.AccountQueries
    
      # It's handy to have the first argument default to Multi.new()
      # - this way such a batch definition will be fully composable
      # with others.
      def transfer_money(multi \\ Multi.new(), account_id_1, account_id_2, amount) do
        multi
        |> Multi.all(
          :retrieve_accounts_step,
          AccountQueries.account_by_ids([account_id_1, account_id_2])
        )
        |> run_verify_balances_step(amount)
        |> Multi.update(:subtract_from_acc_1_step, &subtract_from_acc_1_step/1)
        |> Multi.update(:add_to_acc_2_step, &add_to_acc_2_step/1)
      end
    
      defp run_verify_balances_step(multi, amount) do
        Multi.run(
          multi,
          :verify_balances_step,
          fn _repo, %{retrieve_accounts_step: [acc_1, _]} ->
            if Decimal.compare(acc_1.balance, amount) == :lt,
              do: {:error, :balance_too_low},
              else: {:ok, amount}
          end
        )
      end
    
      defp subtract_from_acc_1_step(changes) do
        %{
          retrieve_accounts_step: [acc_1, _],
          verify_balances_step: verified_amount
        } = changes
    
        Account.changeset(acc_1, %{balance: Decimal.sub(acc_1.balance, verified_amount)})
      end
    
      defp add_to_acc_2_step(%{
             retrieve_accounts_step: [_, acc_2],
             verify_balances_step: verified_amount
           }) do
        Account.changeset(acc_2, %{balance: Decimal.add(acc_2.balance, verified_amount)})
      end
    end

    Notice how we now not only use run/3, but we now also use all/4 that takes an Ecto Query (handily constructed using the concept of Query Modules, and update/4 that takes a changeset or a function that builds such a changeset - similarly, functions corresponding to one or insert also exist - look them up in the Ecto docs. An example of using these shorthand functions is also available in GitHub.

    Then, let's define a module for constructing batches related to the Orders context - nothing special happens there, but just for completeness here it is (as well as in GitHub):

    defmodule TransactApp.Orders.Batches do
      # Ecto.Multi batch definitions for the Orders context
    
      alias Ecto.Multi
      alias TransactApp.Orders
    
      def finalize_order(multi \\ Multi.new(), order) do
        # Notice that, in this function, each step does not depend
        # on any results of previous ones, so we don't need to pass functions
        # as the last arguments of update/3.
        multi
        |> Multi.update(
          :finalize_order,
          Orders.set_order_status_changeset(order, :finalized)
        )
        |> Multi.update(
          :set_order_line_items_locked,
          Orders.lock_order_line_items_changeset(order)
        )
      end
    end

    Finally, wrapping it all up, here's a service module that represents actually checking out an order - Repo.transaction() finally executes the recipe we composed using the OrderBatches and BankBatches modules. Find it in GitHub, too.

    defmodule TransactApp.CheckoutService do
      # Cross-context service, finalizing order and transferring money between
      # parties
      alias Ecto.Multi
      alias TransactApp.Bank.Batches, as: BankBatches
      alias TransactApp.Orders.Batches, as: OrderBatches
      alias TransactApp.Repo
    
      def run(order) do
        OrderBatches.finalize_order(order)
        |> BankBatches.transfer_money(
          order.ordering_account_id,
          order.selling_account_id,
          order.total
        )
        |> Repo.transaction()
      end
    end

    Each Multi pipeline step will return {:ok, ...} or {:error, ...} - and Ecto will commit or rollback the transaction depending on that. No need to explicitly call Repo.rollback/1 or raise any errors.

    TransactApp.Bank.Batches.transfer_money(1, 2, 50)
    |> TransactApp.Repo.transaction()
    
    # returns tuple with map denoting results of each step:
    {:ok, %{retrieve_accounts_step: ..., verify_balances_step: ..., subtract_from_a_step: ..., add_to_b_step: ...}}
    
    # or if an error occurs, it'll return the exact point of failure _and_ all previous step results, which is very handy:
    {:error, :verify_balances_step, :balance_too_low, %{retrieve_accounts_step: ...}}

    script that pattern matches on this call and all the other modules described above are also available in our GitHub repository, so you can play around with it.

    Conclusion

    Several Elixir libraries, including Ecto, benefit from Elixir's nature of a functional language, and allow for easy definition of data processing pipelines. Its transaction handling facilities are just cool and simply get out of your way.

    Since handling database transactions is an aspect that no serious application can evade, it's awesome that Elixir allows developers to manage them elegantly, which leads to software that does its business well.

    unhandled error occurs transaction function database tables schema module database adapter update operations need to create query specific operations following example repo module postgres adapter

    FAQ

    What is a database transaction in Ecto and Elixir?

    A database transaction in Ecto and Elixir refers to a set of operations that are executed as a single unit, ensuring atomicity, consistency, isolation, and durability (ACID properties).

    How does Ecto handle database transactions in Elixir applications?

    Ecto handles database transactions through the Ecto.Repo.transaction/1 function, enabling atomic operations where all changes are either committed together or rolled back.

    What are the benefits of using Ecto.Multi in Elixir?

    Ecto.Multi allows the composition of multiple database operations into a single transaction, facilitating error handling and ensuring that all operations either succeed together or fail without making partial changes.

    How do transactions in Ecto improve data consistency?

    Transactions in Ecto ensure data consistency by adhering to ACID properties, which means that all operations within a transaction are treated as a single unit.

    How can one use Ecto.Multi to organize transactional code in Elixir?

    Ecto.Multi is used to organize transactional code by chaining multiple database operations, enabling clearer and more manageable transactional logic.

    What are the common patterns for handling errors in Ecto transactions?

    Common patterns include using pattern matching with the results of Ecto.Repo.transaction/1, and handling different outcomes explicitly to ensure robust error handling.

    How does the functional nature of Elixir influence Ecto transaction handling?

    The functional nature of Elixir, with features like the pipe operator and pattern matching, facilitates composing and managing transactional code effectively, contributing to cleaner and more maintainable code.

    Michał Buszkiewicz, Elixir Developer
    Michał Buszkiewicz Curiosum Founder & CTO

    Read more
    on #curiosum blog