Modular v0.1.0 Modular.AreaAccess View Source
Ensures that module access is limited to given area's public contract.
You want to divide your code into cohesive areas* that group common functionalities. These areas should provide external interfaces that act as contracts with clients and they should encapsulate implementation details inside their private parts so that it is impossible (and just unsuggested) for any of the clients to know about or depend upon these details.
* We could replace the word "area" with "bucket", "context", "component", "package", "assembly" or... "module" - but not as in "Elixir module defined via
defmodule
" but rather as "separate software unit that implements related functionality, expresses external interface and encapsulates implementation details". We'll stick to the word "area" though.
In Elixir, we organize and encapsulate code into modules with defp
as a tool for hiding the
in-module private implementations. It's useful as long as the code fits into single module, but
there's no means of access control across multiple modules (at least not
yet). There's also a
convention to differentiate modules as either public APIs or private implementations by filling
the @moduledoc
attribute with contract documentation or false
respectively (as explained in
official Elixir guidelines about writing
docs). It's
better than nothing, but as all modules are equally accessible and the meaning of @moduledoc
is
often misinterpreted, there's no out-of-the-box solution for implementing areas formed out of
multiple modules and ensuring that no client will cross the boundary of area interface.
This check aims to solve these problems by mapping dependencies among modules and verifying that the boundary of area is not abused. It applies the following ground rules:
Areas are formed from a root module that has
@moduledoc
string filled and that acts as interface and from arbitrary number of implementation submodules with@moduledoc false
.Area interfaces are globally accessible while area implementations are only accessible by modules within the same area (ie. that share the same area root module).
Areas may be nested within other areas in which case they are also globally accessible and they are stil considered independent from their parent areas (ie. they can only reference parent interface and the parent can only reference their interface).
Usage
Include the check in your .credo.exs
:
%{
configs: [
%{
name: "default",
checks: [
{Modular.AreaAccess, ignore_callers: [~r/Test$/]}
]
}
]
}
You can specify the following options:
ignore_callers
- all caller modules matching this regex (or list of regexes) will be ignoredignore_deps
- all references to modules matching this regex (or list of regexes) will be ignored
Example
Let's consider the following application:
defmodule Invoicing do
@moduledoc "Issues and manages client invoices."
@doc "Create new invoice with specified items."
def create_invoice(items), do: __MODULE__.CreateInvoice.call(items)
@doc "Send specified invoice to specified e-mail address."
def create_invoice(invoice_id, email), do: __MODULE__.SendInvoice.call(invoice_id, email)
end
defmodule Invoicing.Repo do
@moduledoc false
# ...
end
defmodule Invoicing.CreateInvoice do
@moduledoc false
def call(items) do
invoice = Invoicing.Invoice.build(items)
Invoicing.Repo.insert!(invoice)
Invoicing.SendInvoice.call(invoice.id, "invoices@backoffice.com")
invoice
end
end
defmodule Invoicing.SendInvoice do
@moduledoc false
def call(invoice_id, email), do: # ...
end
defmodule Invoicing.Invoice do
@moduledoc "Represents an issued invoice."
defstruct [:id, :items, :number]
def build(items) do
%__MODULE__{
id: UUID.uuid4(),
items: items,
number: __MODULE__.GenerateNumber.call()
}
end
end
defmodule Invoicing.Invoice.GenerateNumber do
@moduledoc false
def call do
# ...
end
end
Here's how the check applies to the example above:
Invoicing
is the main publicly accessible area so it fills@moduledoc
.Invoicing
puts its implementation into private servicesCreateInvoice
andSendInvoice
.Invoicing
returnsInvoice
to external clients thereforeInvoice
is also public.Invoice
as separate area also has its own private serviceGenerateNumber
.Invoicing
can't directly useGenerateNumber
andInvoice
can't use eg.SendInvoice
.
Notes
It's impossible to map all dependencies of modules in Elixir due to dynamic nature of the language (eg. the
apply/3
function). Credo check is further limited by executing static AST analysis without compilation and only on a set of linted files. It's a best effort solution.Current implementation of this check follows idiomatic Elixir with a supoort for just one global level of publicity indicated via
@moduledoc
string - there's no concept of "public within an app or within specific parent area" although there are discussions about adding it to Elixir language. Right now, you must choose between eg. making app-wide modules public for sake of accessing them in nested areas or wrapping them in single larger area.This check ignores deps that have undefined publicity, so it's recommended to complement it with Credo's own
Credo.Check.Readability.ModuleDoc
in order to ensure that all modules are forced to define it via the@moduledoc
attribute.In current implementation, usage of dependencies is not tracked down to specific points inside the caller module, but rather to its
defmodule
. This may be an inconvenience but it also reduces the number of duplicate issues for multiple references to the same dep within a single caller module.For unit testing purposes, access to private modules is allowed for callers with the same name, just with
Test
suffix appended. Of course, you may just as well decide to exclude all test files from the check by settingignore_callers: [~r/Test$/]
.
Link to this section Summary
Functions
Returns the base priority for the check.
Returns the category for the check.
Callback implementation for Credo.Check.explanation/0
.
Callback implementation for Credo.Check.explanation_for_params/0
.
Callback implementation for Credo.Check.format_issue/2
.
Callback implementation for Credo.Check.run_on_all?/0
.
Link to this section Functions
base_priority() View Source
Returns the base priority for the check.
Callback implementation for Credo.Check.base_priority/0
.
category() View Source
Returns the category for the check.
Callback implementation for Credo.Check.category/0
.
elixir_version() View Source
explanation() View Source
Callback implementation for Credo.Check.explanation/0
.
explanation_for_params() View Source
Callback implementation for Credo.Check.explanation_for_params/0
.
format_issue(issue_meta, opts) View Source
Callback implementation for Credo.Check.format_issue/2
.
params_defaults() View Source
params_names() View Source
run(source_files, exec, params \\ []) View Source
run_on_all?() View Source
Callback implementation for Credo.Check.run_on_all?/0
.