An ordered list of layers can be configured at the top level of tach.toml, and modules can each be assigned to a specific layer.

How does it work?

Layered architecture is often an effective starting point for modularizing an application.

The idea is straightforward: Higher layers may import from lower layers, but lower layers may NOT import from higher layers.

Defining this architecture is more concise and flexible than specifying all module dependencies with depends_on, which makes it easier to adopt in an existing project.

Tach allows defining and enforcing a layered architecture with any number of vertically-stacked layers.

When a module is assigned to a layer, this module:

  • may freely depend on modules in lower layers, without declaring these dependencies
  • must explicitly declare dependencies in its own layer
  • may never depend on modules in higher layers, even if they are declared

Closed Layers

By default, modules in higher layers can import from any lower layer. However, you can mark a layer as “closed” to prevent modules in higher layers from importing modules in lower layers.

For example, with layers A, B, and C (high to low), if B is a closed layer, then A cannot import C directly, even though C is lower than A. This is useful for enforcing strict boundaries between architectural tiers.

When a layer is closed, it acts as an intermediary - modules in higher layers must go through the closed layer to access modules in lower layers. This helps enforce architectural boundaries and prevents direct coupling between distant layers.

You can specify a closed layer by using an in-line object in the layers array:

# Shorthand format (defaults to closed = false)
layers = ["ui", "commands", "core"]

# Object format with closed property
layers = [
    "ui",
    { name = "commands", closed = true },
    "core"
]

In this example, if commands is a closed layer:

  • ui modules can import from commands modules
  • commands modules can import from core modules
  • ui modules CANNOT import directly from core modules
  • ui modules must go through commands modules to access core functionality

Example

We can use the Tach codebase itself as an example of a 3-tier layered architecture:

layers = [
  "ui",
  "commands",
  "core"
]

[[modules]]
path = "tach.check"
layer = "commands"

[[modules]]
path = "tach.cache"
depends_on = ["tach.filesystem"]
layer = "core"

[[modules]]
path = "tach.filesystem"
depends_on = []
layer = "core"

In the configuration above, three layers are defined. They are similar to the classic Presentation - Business Logic - Data which are often found in web applications, but a bit different given that Tach is a CLI program.

In Tach, the highest layer is UI, which includes code related to the CLI and other entrypoints to start the program.

Just below this, the Commands layer contains high-level business logic which implements each of the CLI commands.

At the bottom is the Core layer, which contains utilities, libraries, and broadly relevant data structures.

Given this configuration, tach.check does not need to declare a dependency on tach.cache or tach.filesystem to use it, because the Commands layer is higher than the Core layer.

However, tach.cache needs to explicitly declare its dependency on tach.filesystem, because they are both in the Core layer.