Yes that makes sense
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).
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
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.
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.
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.
A couple of ideas:
- 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. - Broadcast through plain Phoenix.PubSub instead.
- 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. - 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.
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:
- Move all the logic from the child modules into the context module.
- Use the child modules just as a pure data structures.
- 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
- Move all the logic from the child modules into some kind of
Billing.Action.*
orBilling.Service.*
modules which will be basically just the one bigBilling
context module split into multiple smaller ones to improve readability and maintainability. - Move the pure data structures under
Billing.Data.*
for example. - Then I can do
use Boundary, exports: [Data]
in my context module.
What do you think? Any insight would be very appreciated. Thanks.
Great question, and also I think a great example of how boundary motivates developers to think about the code organization 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.
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.