Boundary - enforcing boundaries in Elixir projects

Yes that makes sense :+1:

I switched the interface to the one I mentioned in the second half of this post. Basically, now we can do:

defmodule MySystemWeb do
  use Boundary, deps: [MySystem, Ecto.Changeset]

Which will restrain usage of ecto to only Ecto.Changeset boundary. If the external app defines its own boundaries, the specs of those boundaries will define what can be invoked. Otherwise, if the app doesn’t define any boundary (like in this case), an implicit boundary is created which will contain all modules from the given namespace (like e.g. Ecto.Changeset.Foo.Bar). An implicit boundary always exports all of its modules.

More details are available in this part of the moduledoc.

As I suggested in the aforementioned post, I also plan to add some support for strict mode, which will require us to explicitly allow the usage of any external boundary. I’m not completely clear on the details, but the rough idea is to allow a per-boundary setting (e.g. via externals: :strict), as well as global default (e.g. by providing externals: :strict in mix project config).

1 Like

0.3.0 is now available on hex. You can find the changelog here.

3 Likes

Folks,

I’ve been busy for the past few days, and made a first draft of the nested boundaries support. You can find the relevant docs here. It’s worth noting that the impl in the related branch is not working exactly as advertised in docs, but that’s a minor detail which will be fixed :laughing:

But at this point I’d like to get some feedback on this. Do you think it makes sense? Do you see some pitfalls?

While I’m at it, I also added another feature called mass-exporting. It’s added due to the need of the project I’m using for dogfooding boundary. The relevant docs are here. I’m not completely convinced about the interface, and I’m interested in some feedback on this.

7 Likes

Hey all,

I am trying to use this library in our project to enforce boundaries, but I am struggling a bit with this situation.

When using the example from the docs:

defmodule MySystem.Application do
  use Boundary, top_level?: true, deps: [MySystem, MySystemWeb]
  # ...
end

defmodule MySystem do
  use Boundary, deps: [], exports: []

  def create_user(name) do
    # creating user...
    MySystemWeb.Endpoint.broadcast!("notifications", "user_created", %{"name" => name})
  end
end

defmodule MySystemWeb do
  use Boundary, deps: [MySystem], exports: [Endpoint]
  # ...
end

I am (naturally) getting this warning:

forbidden reference to MySystemWeb.Endpoint
  (references from MySystem to MySystemWeb are not allowed)

How should I organize my code to prevent this kind of boundaries violation? I can’t move the broadcast! call into the controller, because sometimes the MySystem.create_user/1 function can be called from another place.

Thanks a lot for your suggestions and thanks @sasajuric for this cool library. :slight_smile:

1 Like

That’s I guess the missing piece here. Do you want to live with that coupling? If yes then Endpoint is a dependency. If not then you need to figure out a way to have create_user work even when the endpoint is not available.

1 Like

A couple of ideas:

  1. Make create_user return something like [Notification.t()] (i.e. a list of notifications), leaving it to the caller to send notifications. This will work as long as in every execution path there is a caller (direct or indirect) which is from the web namespace.
  2. Broadcast through plain Phoenix.PubSub instead.
  3. Have create_user accept a notifier as dependency (a parameter which is a function or a module implementing some notifier behaviour). The limitation is the same as in 1.
  4. A variation of 3 is to get the dependency (e.g. a notifier module) from the app env. The limitation then disappears.

This is roughly the order of my personal preference, though I wouldn’t want to generalize.

3 Likes

Thanks a lot for your responses. I am trying to gradually incorporate Boundary into our project and I think I need another advice or a bit of brainstorming about module organization.

I am creating a context called Billing that does all things related to billing. So imagine this very simplified situation:

defmodule MySystem.Billing do
  alias MySystem.Billing.Subscription
  alias MySystem.Billing.PaymentMethod
  # ...
  
  @spec create_subscription(String.t(), integer()) :: 
    {:ok, Subscription.t()} | {:error, :blah}
  def create_subscription(customer_name, quantity) do
    case something() do
      nil ->
        {:error, :blah}

      something ->
        Subscription.create(customer_name, quantity, something)
    end
  end

  def cancel_subscription(subscription_id) do
    # ...
  end
end
defmodule MySystem.Billing.Subscription do
  defstruct [:customer_name, :quantity, :something]
  @type t :: %__MODULE__{
    # ... 
  }

  @spec create(String.t(), integer(), any()) :: {:ok, t()}
  def create(customer_name, quantity, something) do
    # call API directly
  end
end

Context module has all the business logic and the child modules are just data structures with a basic CRUD logic.

I want to hide the CRUD functions to prevent invalid states and at the same time, I want to export the child modules, so I can pattern match on the structs when calling the Billing context functions from controllers, tests or other places.

I can think of two approaches:

  1. Move all the logic from the child modules into the context module.
  2. Use the child modules just as a pure data structures.
  3. That will result into giant context module, but will be very clean from the API perspective. Then I may not even need the Boundary library for this.

or

  1. Move all the logic from the child modules into some kind of Billing.Action.* or Billing.Service.* modules which will be basically just the one big Billing context module split into multiple smaller ones to improve readability and maintainability.
  2. Move the pure data structures under Billing.Data.* for example.
  3. Then I can do use Boundary, exports: [Data] in my context module.

What do you think? Any insight would be very appreciated. Thanks. :slight_smile:

2 Likes

Great question, and also I think a great example of how boundary motivates developers to think about the code organization :slight_smile: You could have of course asked the same questions even without using boundary, but I think that the library makes such design issues more obvious.

I personally wouldn’t keep create & co in the Subscription module. Instead I’d place them in some other context module. This is IMO following the ideas of various elaborate architectural styles, such as Onion, Clean, or DDD, by separating domain entities from use-cases/services.

That doesn’t mean that a “data” module, such as Subscription, shouldn’t have any logic. A frequent example are query functions which return some derived information is fine (e.g. Order.total_price which sums the price of each order item). But in general, I avoid adding db logic into such modules.

So this begs the question where should create reside? I initially put it in the same module where it’s used (in this case MySystem.Billing). If the code of that module grows, I’ll consider possible split opportunities, e.g. extracting peer modules (i.e. top-level exported context modules), and/or moving some parts into one or more internal modules. The actual split decision depends on the actual code. As an aside, there’s a helper task mix boundary.visualize.funs, which will generate the graph of in-module cross-function dependencies. This can be helpful when choosing a split strategy.

Where should we store data modules? I don’t have a clear answer. First, I should note that I never used a pure Ecto-independent data model. But even with Ecto schemas, this challenge is similar. In the past I stashed my Ecto schemas under MySystem.Schemas.*. A nice perk is that you can then mass export all such modules. Perhaps a more important benefit of such organization is that you don’t have to deal with weird names, such as Accounts (use-case) and Accounts.Account (data), which I find cringey. When data modules are stashed under a separate namespace, then we can easily use e.g. Data.Subscription (data) and Subscription (use-case) in the same client module.

4 Likes

Thank you for you ideas and opinions Saša.

You’re right, that now I can see better the issues of my design. Before that it was just a feeling and a small annoyance here and there. Now I can fix that step by step and gain a clear picture thanks to the explicit boundaries.

I remember some of the things you mentioned here from your Maintainable Elixir blog series. I think I have to read them again (and then again :-)).

Thanks again. I have a lot to think about and to refactor.