diff --git a/ChangeLog.md b/ChangeLog.md
new file mode 100644
--- /dev/null
+++ b/ChangeLog.md
@@ -0,0 +1,5 @@
+# Changelog for guardian
+
+## 0.4.0.0
+
+- This is the first official OSS release! :tada:
diff --git a/LICENSE b/LICENSE
new file mode 100644
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,30 @@
+BSD 3-Clause License
+
+Copyright (c) 2023, DeepFlow, Inc.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+    * Redistributions of source code must retain the above copyright
+      notice, this list of conditions and the following disclaimer.
+
+    * Redistributions in binary form must reproduce the above
+      copyright notice, this list of conditions and the following
+      disclaimer in the documentation and/or other materials provided
+      with the distribution.
+
+    * Neither the name of DeepFlow, Inc. nor the names of other
+      contributors may be used to endorse or promote products derived
+      from this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/README.md b/README.md
new file mode 100644
--- /dev/null
+++ b/README.md
@@ -0,0 +1,529 @@
+# guardian - The border guardian for your package dependencies
+
+- [Introduction - How to keep your Haskell monorepo sane](#introduction---how-to-keep-your-haskell-monorepo-sane)
+  + [Dependency Inversion Principle](#dependency-inversion-principle)
+  + [The emergence of Guardian - package dependency domain isolation in practice](#the-emergence-of-guardian---package-dependency-domain-isolation-in-practice)
+  + [Summary](#summary)
+- [Installation](#installation)
+- [Usage](#usage)
+  + [Actual validation logic](#actual-validation-logic)
+  + [Syntax of `dependency-domains.yaml`](#syntax-of-dependency-domainsyaml)
+    - [Domain Definition](#domain-definition)
+    - [Component Section](#component-section)
+    - [Cabal specific settings](#cabal-specific-settings)
+    - [Stack specific settings](#stack-specific-settings)
+    - [Example Configuration](#example-configuration)
+- [GitHub Actions](#github-actions)
+- [Contribution](#contribution)
+- [Copyright](#copyright)
+
+## Introduction - How to keep your Haskell monorepo sane
+
+Maintaining a large monorepo consisting of dozens of Haskell packages is not easy.
+Sometimes, just making sure all packages compile on CI is not enough - to accelerate the development cycle, it is crucial to ensure that changes to the codebase trigger only necessary rebuilds as far as possible.
+
+But enforcing this requirement by hand is not easy.
+To make things work, a kind of people known as programmers - especially Haskellers, you know - are inherently lazy.
+They are so lazy to grep through the project, shouting out "look, there is what we want!", and adding extra dependency - finally they make everything depend on each other and constitute the gigantic net of dependencies that takes a quarter day even if only a small portion of the code was changed.
+
+Indeed, this was exactly the situation we developers at DeepFlow faced in 2021.
+Our main product is a high-performance numeric solver[^1], consisting of ~70 Haskell packages, which sums up to ~150k lines of code.
+It includes:
+
+- abstraction of numeric computation backend,
+- its concrete implemenatations,
+- abstraction of plugins to extend solvers,
+- its concrete implementations,
+- abstraction of equation system,
+- its concrete implementations,
+- abstraction of communication strategies,
+- its concrete implementations (e.g. MPI, standalone),
+- abstract solver logic, and
+- concrete solver implementations putting these all together.
+
+What we had in 2021 was the chaotic melting pot of these players.
+If one changes a single line of a backend, it forces seemingly unrelated plugins to be recompiled.
+If one removes a single redundant instance, then it miraculously triggers the rebuild in some concrete implementation of some specialised solver.
+In any case, it took at most six hours and ~100 GiB to complete, as we make heavy use of type-level hackery and rely on the aggressive optimisation mechanism of GHC[^2].
+
+This situation is far from optimal, sacrifices developer experience, and severely slows down the iteration speed.
+Our product seems dying slowly but surely.
+
+[^1]: For those who are curious, please read our [old slide][old slide] - it is an old presentation, so some details are outdated, but the overall situation is not so different.
+[^2]: The situation, however, gets better as newer GHC releases came out and we refactor the code structure.
+Nowadays, it takes at most two and a half hours and only ~20GiB.
+The detail of such change is out of the scope of this article.
+
+### Dependency Inversion Principle
+
+In the middle of 2021, we decided to fight against this situation to save our project.
+Our rule of thumb here was _Dependency Inversion Principle_ from the realm of OOP.
+That says:
+
+> 1. High-level modules should not import anything from low-level modules. Both should depend on abstractions
+> 2. Abstractions should not depend on details. Details (concrete implementations) should depend on abstractions.
+>
+> (_excerpt from [Wikipedia][dip]; renumbering is due to the author_)
+
+This is called dependency _inversion_ because it seems inverse to the traditional usage of inheritance in OOP.
+We decided to apply this principle to package dependencies.
+
+We divided the packages into several groups, called _dependency domains_.
+We drew the term and intuition from the [blog post by GitHub about partitioning databases into domains][gh-db-doms].
+
+Our packages are divided into the following groups[^3]:
+
+- `infra`: providing common infrastructure, such as data containers and algorithms.
+- `highlevel`: high-level abstractions of backends, equations, and plugins.
+- `lowlevel`: low-level and concrete implementations.
+- `plugin`: concrete plugin implementations
+- `solver`: concrete solver implementations.
+- `tool`: misc utility apps independent of solvers.
+- `test`: test packages, mainly providing cross-package integration tests.
+
+Any package in monorepo must be classified into exactly one dependency domain.
+Furthermore, we define constraints on the dependencies between domains: each domain is given the set of domains on which its members can (transitively) depend, and the induced dependency graph must form a directed acyclic graph.
+
+The example is as follows:
+
+```mermaid
+flowchart TD
+  test[test] --> solver[solver];
+  test --> tool[tool];
+  highlevel[highlevel] --> infra[infra];
+  plugin[plugin] --> highlevel;
+  lowlevel[lowlevel] --> infra;
+  solver[solver] --> highlevel;
+  solver --> lowlevel;
+  solver --> plugin;
+  tool --> lowlevel;
+  tool --> plugin;
+```
+
+Each package of a domain can depend only on the packages in the same or downstream package in the diagram.
+Note that, in this setting, high-level (or abstract) packages can only depend on another abstraction or infrastructure package and only the application packages (say, `solver` and `tool) can depend both on abstractions and implementations.
+
+[dip]: https://en.wikipedia.org/wiki/Dependency_inversion_principle
+[old slide]: https://speakerdeck.com/konn/da-gui-mo-shu-zhi-ji-suan-wozhi-eru-haskell-nil-nil-pragmatic-haskell-in-large-scale-numerical-computation-nil-nil
+[gh-db-doms]: https://github.blog/2021-09-27-partitioning-githubs-relational-databases-scale/
+[^3]: These are not exactly what we have in reality but in simplified form.
+
+### The emergence of Guardian - package dependency domain isolation in practice
+
+So far, so good.
+Now that we have the grand picture of the package dependency hierarchy, it is time to make things laid out.
+
+The problem is: enforcing such an invariant by hand is almost impossible as the number of packages gets larger and larger.
+Even after we sorted out where the violation occurred, it takes much time to fix them all up while continuing to add new features and fix bugs.
+Furthermore, even if we had worked out things once somehow, our laziness could strike back to slowly violate such constraints and make everything rotten again. Uh-oh.
+
+... Not really. Here, our brave `guardian` can help us!
+
+`Guardian` is a tool designed for this purpose and developed in DeepFlow. We are running `guardian` on our CI for almost two years.
+
+In guardian, we declare the whole dependency constraints in `dependency-domains.yaml`.
+The above constraints are expressed as follows:
+
+```yaml
+domains:
+  infra: 
+    depends_on: []
+    packages:
+    - algorithms
+    - geometry
+    - linalgs
+  highlevel: 
+    packages:
+    - numeric-backends
+    - plugin-base
+    - equation-class
+    depends_on: [infra]
+  lowlevel:
+    packages:
+    - reference-backend
+    - fast-backend
+    - mesh-format
+    depends_on: [infra]
+  plugin:
+    packages:
+    - plugin-A
+    - plugin-B
+    depends_on: [highlevel]
+  solver:
+    packages:
+    - solver-standalone
+    - solver-parallel
+    depends_on: [plugin, lowlevel, highlevel]
+  tool:
+    packages:
+    - post
+    - pre
+    depends_on: [plugin, lowlevel]
+  test:
+    packages:
+    - integration-tests
+    - regression-tests
+    depends_on: [solver, tool]
+
+# We track dependencies in test & benchmark components as well.
+components:
+  tests: true
+  benchmarks: false
+```
+
+Note that we don't have to include `highlevel` into the dependencies of `solver` - it is already introduced via dependency on `plugin`.
+Here, we include `highlevel` just for documentation.
+
+Actually, this is not the first rule we had.
+As mentioned above, our codebase consists of ~70 packages summing up to ~150k lines of code.
+In some cases, it takes much effort to remove dependency violating the dependency domain boundary without sacrificing performance.
+
+To accommodate such cases, `guradian` provides _exception rules_ for compromise.
+It looks like this[^4]:
+
+```yaml
+...
+  plugin:
+    packages:
+    - package: plugin-A
+      exception:
+        depends_on:
+        - lowlevel
+        - package: fast-backend
+    - plugin-B
+    depends_on: [highlevel]
+...
+```
+
+As shown above, exception rules are specified per-package manner.
+It can allow dependencies to other dependency domains and/or individual package which is otherwise banned.
+An exception rule doesn't affect the transitive dependency - only the dependencies of the specified package are exempted.
+In the above example, only `plugin-A` is allowed to depend on the exempted targets.
+So, if `plugin-B` depends on `plugin-A`, it cannot depend on `fast-backend` package explicitly.
+
+The point is that exception rules must be considered as tentative compromises - they must be removed at some points to enforce dependency inversion at the package level to keep the entire project healthy.
+To make it clear, `guardian` will emit warnings when the exception rules are used:
+
+```log
+[info] Using configuration: dependency-domains.yaml
+[info] Checking dependency of /path/to/project/" with backend Cabal
+[warn] ------------------------------
+[warn] * Following exceptional rules are used:
+[warn]     - "A2" depends on: PackageDep "B1"
+[info] ------------------------------
+[info] All dependency boundary is good, with some additional warning.
+```
+
+And when the situation allows some rules to be lifted, it also tells us as follows:
+
+```log
+[warn] * 1 redundant exceptional dependency(s) found:
+[warn]     - "A2" doesn't depends on: PackageDep "B1"
+```
+
+So, what `guardian` would do is:
+
+- Check if all the packages are disjointly classified into dependency domains.
+- Makes sure that the dependency graph between each domain forms a DAG.
+- Check if all the dependency constraint is met, except for prescribed exceptions.
+- Report the validation results with information about used/redundant exception rules.
+
+One thing to note is that `guardian` checks dependency constraints based solely on the dependencies specified in `*.cabal` and/or `package.yaml` file.
+In other words, guardian doesn't treat a dependency introduced by module imports.
+This is because such a dependency is already handled by the compiler.
+
+In our case, we had two exception rules enabled at first and it took almost half a year to finally abolish all of the exception rules.
+This was finally done when the major rework on the entire structure of our product.
+Indeed, one of the motivations of the refactoring was to completely deprecate the exception rules and the overall dependency graph above was the driving force to make out the correct design of the entire package structure.
+In this way, the presence of exception rules in the dependency boundary constraints signals the design flaw in the package hierarchy and gives us a useful guideline for a redesign.
+
+The benefit of dependency constraint enforcement by guardian is not limited to refactoring.
+Running `guardian` on CI, one can always check the sanity of the entire structure when adding new features/packages to the monorepo.
+When one adds new packages to the monorepo, guardian force us to think in which domain to put them.
+If we accidentally add package dependencies violating constraints in the midway of adding new features, guardian will tell us it generously.
+In this way, guardian helps the product evolve healthily while preventing getting rotten.
+
+[^4]: As package `fast-backend` is already included in `lowlevel` domain, we don't have to include `fast-backend` as a separate exception rule. Here, we included it for the exposition.
+
+### Summary
+
+With guardian, you can:
+
+- divide monorepo packages into disjoint groups called _dependency domains_,
+- define the topology of DAG of dependency domains to secure dependency boundaries,
+- you can still specify exception rules for compromise - they will be warned so that you can finally remove it.
+
+Equipped with these features, we can:
+
+- Keep the package hierarchy of the monorepo clean and loose-coupled.
+- Be more careful when adding new packages/features as guardian shouts at us when violations are found.
+
+There are several possibilities in the design of actual DAG of domains, we recommend the following rules:
+
+- Separate abstractions and concrete implementations as far as possible.
+  + Ideally, applications and/or tests can depend on both.
+  + Keep DIP in mind: Abstractions SHOULD NOT depend on implementations. Implementation should depend on abstractions.
+- Refine domains based on semantics/purposes.
+- Use dependency domain constraints as a guideline for the design of the overall monorepo.
+  + It guides us in making out the "correct" place to put new packages/features.
+  + The number of exception rules indicates the code smell.
+
+## Installation
+
+You can download prebuilt binaries for macOS and Linux from [Release](https://github.com/deepflowinc/guardian/releases/latest).
+
+You can also use [GitHub Action](#github-actions) in your CI.
+
+To build from source, we recommend using `cabal-install >= 3.8`:
+
+```sh
+git clone git@github.com/deepflowinc/guardian.git
+cd guardian
+cabal install
+```
+
+## Usage
+
+```sh
+guardian (auto|stack|cabal) [-c|--config PATH] [DIR]
+```
+
+Subcommand `auto`, `stack`, `cabal` specifies the _adapter_, i.e. build-system, to compute dependency.
+
+- `stack`: uses Stack (>= 2.9) as an adapter.
+- `cabal`: uses cabal-install (>= 3.8) as an adapter.
+- `auto`: determines adapter based on the directory contents and guardian configuration.
+  + If exactly one of `cabal.project` or `stack.yaml` is found, use the corresponding build system as an adapter.
+  + If exactly one of the custom config sections (say, `cabal:` or `stack:`) is found in the config file, use the corresponding build system.
+
+Note that `guardian` links directly to `stack` and `cabal-install`, so you don't need those binaries.
+Make sure the project configuration is compatible with the above version constraint.
+
+Optional argument `DIR` specifies the project root directory to check. If omitted, the current working directory is used.
+
+The option `--config` (or `-c` for short) specifies the path of the guardian configuration file relative to the project root.
+If omitted, `dependency-domains.yaml` is used.
+
+### Actual validation logic
+
+When invoked, guardian will check in these steps:
+
+1. Defines a dependency graph based on the package dependencies.
+2. Checks if the graph forms DAG.
+3. Checks if the dependency domain constraints are satisfied first ignoring exception rules.
+4. If violating dependency is detected, exempt it if it is covered by any exception rule.
+     + If any exception rule can be used, warn of its use; otherwise, report it as an error.
+5. Report results, warning about used and redundant exception rules.
+
+### Syntax of `dependency-domains.yaml`
+
+Guardian configuration file consists of the following top-level sections:
+
+| Section   | Description |
+| --------- | ----------- |
+| `domains` | **Required**. Definition of Dependency Domains (see [Domain Definition](#domain-definition)) |
+| `components` | _Optional_. Configuration whether track test/benchmark dependencies as well (see [Component Section](#component-section)) |
+| `cabal` | _Optional_. Cabal-specific configurations. (see [Cabal specific settings](#cabal-specific-settings)) |
+| `stack` | _Optional_. Stack-specific configurations. (see [stack specific settings](#stack-specific-settings)) |
+
+The ordering of sections is irrelevant.
+
+See [Example Configuration](#example-configuration) for a complete example.
+
+#### Domain Definition
+
+Dependency domains, their members, and constraints are specified in the `domains` section.
+
+The `domains` section must be a dictionary associating each domain label to the domain definition.
+A domain label must match `/[A-Za-z0-9-_]+/`.
+
+Individual domain definition object has the following fields:
+
+| Field | Type | Description |
+| ----- | ---- | ----------- |
+| `depends_on`  | `[String]` | **Required**. Labels of the other domains that the dependency being defined is depending on. |
+| `packages` | `Package` | **Required**. A list of packages of the domain. Each package can be a string or object; see below for detail |
+| `description` | `Maybe String` | _Optional_. A human-readable description of the domain. Note that this field is not processed by guardian. |
+
+Package entry occurring in the `packages` field can either be a string or package object.
+A single string, e.g. `mypackage` is interpreted as a package object with only the `package` field specified, e.g. `{package: "mypackage"}`.
+Package object has the following field:
+
+| Field     | Type     | Description |
+| --------- | -------- | ----------- |
+| `package` | `String` | **Required**. Package name |
+| `exception` | `ExceptionRule` | _Optional_. Package-specific exception rules |
+
+Exception rule object is an object of form `{depends_on: <an array of ExceptionItem's>]}`.
+Exception item can be either a simple string or an object of form `{package: "package-name"}`.
+A single string as an exception rule is interpreted as a dependency on the domain with having the string itself as the label.
+An object `{package: "package-name"}` is interpreted as a dependency on the package `package-name`.
+
+Example:
+
+```yaml
+domains:
+  A: 
+    depends_on: [C]
+    packages: 
+    - A1
+    - package: A2
+      exception: {depends_on: [package: B1]}
+  B: 
+    packages: [B1]
+    depends_on: [C]
+  C: 
+    packages: [C]
+    depends_on: []
+```
+
+In the above example, packages are divided into three domains: `A`, `B`, and `C`.
+Apart from the packages in the same domain, packages in domains `A` and `B` can depend on those in `C`.
+Exception rule is specified for package `A2` in domain `A`, which allows `A2` to directly depend on package `B1`.
+Even if `A1` depends on `A2`, `A1` cannot depend on it directly - this is how exception rules work.
+
+#### Component Section
+
+Sometimes, one wants to exclude special components such as tests or benchmarks from dependency tracking.
+The `component` section is exactly for this purpose.
+It consists of the following optional fields:
+
+| Field | Type | Description |
+| ----- | ---- | ----------- |
+| `tests` | `Bool` | _Optional_. If `true`, tracks tests (default: `true`). |
+| `benchmarks` | `Bool` | _Optional_. If `true`, tracks benchmarks (default: `true`). |
+
+
+The `component` section itself can be omitted - in such cases, all the tests and benchmarks will be tracked for dependency.
+
+#### Cabal specific settings
+
+Configurations specified to `cabal-install` backend can be specified in `cabal` top-level section.
+
+It has the following fields:
+
+| Field | Type | Description |
+| ----- | ---- | ----------- |
+| `projectFile` | `FilePath` | _Optional_. The path of the cabal project file relative to the project root (default: `cabal.project`). |
+| `update` | `Bool` or `String` | _Optional_. If `true`, run (the equivalent of) `cabal update` before dependency checking. If a non-empty string is given, it will be treated as an `index-state` string and passed to the `update` command. (default: `false`) |
+
+Example:
+
+```yaml
+cabal:
+  projectFile: cabal-custom.project
+  update: true
+```
+
+Example with index-state:
+
+```yaml
+cabal:
+  projectFile: cabal-custom.project
+  update: "hackage.haskell.org,2023-02-03T00:00:00Z"
+```
+
+#### Stack specific settings
+
+Stack-specific options are specified in the `stack` top-level section.
+For the time being, it only has the `options` field, which is a possibly empty list of options to be passed to the `stack` command.
+It can be used, for example, to specify the custom `stack.yaml` file as follows:
+
+```yaml
+stack:
+  options: 
+  - "--stack-yaml=stack-test.yaml"
+```
+
+If the `stack` section is omitted, the `options` will be treated as empty.
+
+#### Example Configuration
+
+```yaml
+components: # Specifies whether track test/benchmark dependencies as well:
+  tests: true
+  benchmarks: false
+
+domains:
+  domain-A:
+    depends_on:
+    - common
+    # Domain CANNOT depend on a separate package directly!
+    # - package: B3 # Error!
+    packages:
+    - A1
+    - A2
+    - package: A3
+      exception: # Exception rules for a particular package.
+        depends_on:
+        - C # domain name if a plain string
+        - package: B3 # You can specify a single package name ONLY in package rule.
+  domain-B:
+    depends_on:
+    - common
+    packages:
+    - B1
+    - B2
+    - B3
+  C:
+    depends_on:
+    - common
+    packages:
+    - C1
+  common:
+    depends_on: [] # You MUST specify empty dependency explicitly.
+    packages: 
+    - mybase
+    - urbase
+```
+
+## GitHub Actions
+
+Guardian provides a GitHub Action that can be used in GitHub Workflow.
+
+Prerequisites:
+
+- OS running the action must be either Linux or macOS with the following executables in PATH:
+  + `sha256sum`
+  + `tar`
+  + `curl`
+  + `jq`
+- If you are using the Cabal backend and `with-compiler` is specified explicitly,
+  the corresponding version of GHC must be in the PATH.
+
+Example workflow:
+
+```yaml
+  check-dependecy-boundary:
+    name: Checks Dependency Constraint
+    runs-on: ubuntu-20.04
+    continue-on-error: true
+    steps:
+      - uses: actions/checkout@v3
+      - uses: haskell/actions/setup@v2
+        with:
+          ghc-version: 9.0.2  # Install needed version of ghc
+      - uses: deepflowinc/guardian/action@v0.4.0.0
+        name: Check with guardian
+        with:
+          backend: cabal    # auto, cabal, or stack; auto if omitted
+          version: 0.4.0.0  # latest if omitted
+
+          ## Specify the following if the project root /= repository root
+          # target: path/to/project/root
+
+          ## If you are using non-standard name for config file
+          # config: custom-dependency-domains.yaml 
+```
+
+## Contribution
+
+Please feel free to open an issue, but also please search for existing issues to check if there already is a similar one.
+
+See [CONTRIBUTING.md][CONTRIBUTING] for more details.
+
+[CONTRIBUTING]: ./CONTRIBUTING.md
+
+## Copyright
+
+(c) 2021-2023, DeepFlow Inc.
diff --git a/Setup.hs b/Setup.hs
new file mode 100644
--- /dev/null
+++ b/Setup.hs
@@ -0,0 +1,2 @@
+import Distribution.Simple
+main = defaultMain
diff --git a/app/Main.hs b/app/Main.hs
new file mode 100644
--- /dev/null
+++ b/app/Main.hs
@@ -0,0 +1,9 @@
+{-# LANGUAGE TemplateHaskell #-}
+
+module Main (main) where
+
+import Development.Guardian.App
+import Paths_guardian (version)
+
+main :: IO ()
+main = defaultMain $$(buildInfoQ version)
diff --git a/guardian.cabal b/guardian.cabal
new file mode 100644
--- /dev/null
+++ b/guardian.cabal
@@ -0,0 +1,124 @@
+cabal-version: 2.2
+
+-- This file has been generated from package.yaml by hpack version 0.35.0.
+--
+-- see: https://github.com/sol/hpack
+
+name:           guardian
+version:        0.4.0.0
+synopsis:       The border guardian for your package dependencies
+description:    Guardian secures your Haskell monorepo package dependency boundary. Please read [README.md](https://github.com/deepflowinc/guardian#readme) for more details.
+category:       Development
+homepage:       https://github.com/deepflowinc/guardian#readme
+bug-reports:    https://github.com/deepflowinc/guardian/issues
+author:         DeepFlow, Inc.
+maintainer:     DeepFlow, Inc.
+copyright:      (c) 2021-2023, DeepFlow, Inc.
+license:        BSD-3-Clause
+license-file:   LICENSE
+build-type:     Simple
+extra-source-files:
+    README.md
+    ChangeLog.md
+data-dir:       data
+
+source-repository head
+  type: git
+  location: https://github.com/deepflowinc/guardian
+
+library
+  exposed-modules:
+      Development.Guardian.App
+      Development.Guardian.Constants
+      Development.Guardian.Graph
+      Development.Guardian.Graph.Adapter.Cabal
+      Development.Guardian.Graph.Adapter.Detection
+      Development.Guardian.Graph.Adapter.Stack
+      Development.Guardian.Graph.Adapter.Types
+      Development.Guardian.Types
+  other-modules:
+      Paths_guardian
+  autogen-modules:
+      Paths_guardian
+  hs-source-dirs:
+      src
+  ghc-options: -Wall
+  build-depends:
+      Cabal
+    , Cabal-syntax
+    , aeson
+    , algebraic-graphs
+    , array
+    , base >=4.7 && <5
+    , cabal-install >=3.8
+    , containers
+    , dlist
+    , generic-lens
+    , githash
+    , hashable
+    , indexed-traversable
+    , indexed-traversable-instances
+    , microlens
+    , optparse-applicative
+    , path
+    , path-io
+    , rio
+    , selective
+    , semigroups
+    , stack >=2.9
+    , template-haskell
+    , text
+    , transformers
+    , unordered-containers
+    , validation-selective
+    , vector
+    , yaml
+  default-language: Haskell2010
+
+executable guardian
+  main-is: Main.hs
+  other-modules:
+      Paths_guardian
+  autogen-modules:
+      Paths_guardian
+  hs-source-dirs:
+      app
+  ghc-options: -Wall
+  build-depends:
+      base >=4.7 && <5
+    , guardian
+    , text
+  default-language: Haskell2010
+
+test-suite guardian-test
+  type: exitcode-stdio-1.0
+  main-is: Spec.hs
+  other-modules:
+      Development.Guardian.AppSpec
+      Development.Guardian.Graph.Adapter.CabalSpec
+      Development.Guardian.Graph.Adapter.StackSpec
+      Development.Guardian.Graph.Adapter.TestUtils
+      Development.Guardian.GraphSpec
+      Paths_guardian
+  autogen-modules:
+      Paths_guardian
+  hs-source-dirs:
+      test
+  ghc-options: -Wall
+  build-tool-depends:
+      tasty-discover:tasty-discover
+  build-depends:
+      algebraic-graphs
+    , base >=4.7 && <5
+    , bytestring
+    , containers
+    , guardian
+    , path
+    , path-io
+    , rio
+    , tasty
+    , tasty-hunit
+    , text
+    , unordered-containers
+    , validation-selective
+  default-language: Haskell2010
diff --git a/src/Development/Guardian/App.hs b/src/Development/Guardian/App.hs
new file mode 100644
--- /dev/null
+++ b/src/Development/Guardian/App.hs
@@ -0,0 +1,231 @@
+{-# LANGUAGE ApplicativeDo #-}
+{-# LANGUAGE DeriveAnyClass #-}
+{-# LANGUAGE DerivingStrategies #-}
+{-# LANGUAGE LambdaCase #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE RecordWildCards #-}
+{-# LANGUAGE TemplateHaskell #-}
+
+module Development.Guardian.App (
+  BuildInfo (..),
+  defaultMain,
+  defaultMainWith,
+  reportPackageGraphValidation,
+  buildInfoQ,
+) where
+
+import Control.Applicative ((<**>))
+import qualified Data.Aeson as J
+import Data.Foldable.WithIndex
+import Data.Functor.WithIndex.Instances ()
+import Data.List (intersperse)
+import qualified Data.List.NonEmpty as NE
+import qualified Data.Map.Strict as Map
+import Data.Monoid (Sum (..))
+import qualified Data.Set as Set
+import Data.Version (Version (..), showVersion)
+import qualified Data.Yaml as Y
+import Development.Guardian.Constants (configFileName)
+import Development.Guardian.Graph
+import qualified Development.Guardian.Graph.Adapter.Cabal as Cabal
+import Development.Guardian.Graph.Adapter.Detection (detectAdapterThrow)
+import qualified Development.Guardian.Graph.Adapter.Stack as Stack
+import Development.Guardian.Graph.Adapter.Types
+import Development.Guardian.Types
+import GitHash (GitInfo, giDirty, giHash, tGitInfoCwdTry)
+import Language.Haskell.TH.Syntax
+import qualified Options.Applicative as Opts
+import Path
+import Path.IO (canonicalizePath, getCurrentDir)
+import RIO
+import System.Environment (getArgs)
+import Validation (validation, validationToEither)
+
+data Option = Option
+  { mode :: Maybe StandardAdapters
+  , target :: Maybe (SomeBase Dir)
+  , config :: Path Rel File
+  }
+  deriving (Show, Eq, Ord)
+
+data BuildInfo = BuildInfo {versionString :: String, gitInfo :: Maybe GitInfo}
+  deriving (Show)
+
+buildInfoQ :: Version -> Code Q BuildInfo
+buildInfoQ version =
+  [||
+  BuildInfo
+    { versionString = $$(liftTyped $ showVersion version)
+    , gitInfo = either (const Nothing) Just $$(tGitInfoCwdTry)
+    }
+  ||]
+
+optsPI :: BuildInfo -> Opts.ParserInfo Option
+optsPI BuildInfo {..} = Opts.info (p <**> Opts.helper <**> versions) mempty
+  where
+    gitRev =
+      maybe
+        ""
+        ( \gi ->
+            ", Git revision "
+              <> giHash gi
+              <> if giDirty gi then " (dirty)" else ""
+        )
+        gitInfo
+    verStr = "Guardian Version " <> versionString <> gitRev
+    versions = Opts.infoOption verStr (Opts.long "version" <> Opts.short 'V' <> Opts.help "Prints version string and exit.")
+    inP mode = do
+      config <-
+        Opts.option (Opts.eitherReader parsFileP) $
+          Opts.long "config"
+            <> Opts.short 'c'
+            <> Opts.metavar "PATH"
+            <> Opts.value configFileName
+            <> Opts.showDefault
+            <> Opts.help "configuration file, relative to the target directory"
+
+      target <-
+        optional $
+          Opts.argument (Opts.eitherReader parseDirP) $
+            Opts.help "input directory (uses current directory if missing)" <> Opts.metavar "DIR"
+      pure Option {..}
+    autoP =
+      Opts.info (inP Nothing) $
+        Opts.progDesc "Defends borders against the auto-detected build-system"
+
+    p =
+      Opts.hsubparser
+        ( mconcat
+            [ Opts.command "auto" autoP
+            , Opts.command
+                "stack"
+                ( Opts.info (inP $ Just Stack) $
+                    Opts.progDesc "Defends borders against stack.yaml"
+                )
+            , Opts.command
+                "cabal"
+                ( Opts.info (inP $ Just Cabal) $
+                    Opts.progDesc "Defends borders against cabal.project"
+                )
+            ]
+        )
+
+parseDirP :: String -> Either String (SomeBase Dir)
+parseDirP = first show . parseSomeDir
+
+parsFileP :: String -> Either String (Path Rel File)
+parsFileP = first show . parseRelFile
+
+data GuardianException
+  = InvalidDependencyDomainYaml [DomainGraphError]
+  | PackageGraphErrors [PackageViolation]
+  deriving (Show, Eq, Ord)
+  deriving anyclass (Exception)
+
+eitherResult :: J.Result a -> Either String a
+eitherResult (J.Error s) = Left s
+eitherResult (J.Success a) = Right a
+
+defaultMainWith ::
+  (MonadUnliftIO m, MonadReader env m, HasLogFunc env) =>
+  BuildInfo ->
+  [String] ->
+  m ()
+defaultMainWith buildInfo args = do
+  Option {..} <-
+    liftIO $
+      Opts.handleParseResult $
+        Opts.execParserPure Opts.defaultPrefs (optsPI buildInfo) args
+  targ <- maybe getCurrentDir canonicalizePath target
+  logInfo $ "Using configuration: " <> fromString (fromRelFile config)
+  yaml <- Y.decodeFileThrow (fromAbsFile $ targ </> config)
+  doms <- either throwString pure $ eitherResult $ J.fromJSON yaml
+
+  mode' <- maybe (detectAdapterThrow yaml targ) pure mode
+  logInfo $
+    "Checking dependency of "
+      <> displayShow targ
+      <> " with backend "
+      <> displayShow mode'
+  pkgGraph <- case mode' of
+    Stack ->
+      liftIO $
+        either throwString (Stack.buildPackageGraph . flip withTargetPath targ) $
+          eitherResult $
+            J.fromJSON yaml
+    Cabal ->
+      liftIO $
+        either throwString (Cabal.buildPackageGraph . flip withTargetPath targ) $
+          eitherResult $
+            J.fromJSON yaml
+  reportPackageGraphValidation doms pkgGraph
+
+reportPackageGraphValidation ::
+  (MonadReader env m, MonadIO m, HasLogFunc env) =>
+  Domains ->
+  PackageGraph ->
+  m ()
+reportPackageGraphValidation doms pkgGraph = do
+  let mdomInfo = buildDomainInfo doms
+  domInfo <-
+    validation
+      ( \errs -> do
+          logError "Errors exists in dependency domain definition!"
+          mapM_ (logError . displayShow) errs
+          exitFailure
+      )
+      pure
+      mdomInfo
+  let chkResult =
+        validationToEither $
+          validatePackageGraph domInfo pkgGraph
+  case chkResult of
+    Left errs -> do
+      logError $
+        "Dependency domain violation(s): found "
+          <> displayShow (NE.length errs)
+      mapM_ (logError . displayShow) errs
+      exitFailure
+    Right Ok -> logInfo "All dependency boundary is good!"
+    Right (OkWithDiagnostics Diagnostics {..}) -> do
+      unless (Map.null usedExceptionalRules) $ do
+        logWarn "------------------------------"
+        logWarn "* Following exceptional rules are used:"
+        iforM_ usedExceptionalRules $ \pkg deps -> do
+          let ds = Set.toList deps
+          logWarn $
+            "    - "
+              <> displayShow pkg
+              <> " depends on: "
+              <> fold
+                (intersperse "," $ map displayShow ds)
+
+      unless (Map.null redundantExtraDeps) $ do
+        let reduntCount = getSum $ foldMap (Sum . Set.size) redundantExtraDeps
+        logWarn "------------------------------"
+        logWarn $
+          "* "
+            <> displayShow reduntCount
+            <> " redundant exceptional dependency(s) found:"
+        iforM_ redundantExtraDeps $ \pkg deps -> do
+          let ds = Set.toList deps
+          logWarn $
+            "    - "
+              <> displayShow pkg
+              <> " doesn't depends on: "
+              <> fold (intersperse ", " $ map displayShow ds)
+              <> "\n"
+
+      logInfo "------------------------------"
+      logInfo "All dependency boundary is good, with some additional warning."
+
+defaultMain :: MonadUnliftIO m => BuildInfo -> m ()
+defaultMain binfo = do
+  logOpts <-
+    logOptionsHandle stdout True
+      <&> setLogUseTime False
+      <&> setLogUseLoc False
+  withLogFunc logOpts $ \logFun -> do
+    app <- mkSimpleApp logFun Nothing
+    runRIO app $
+      defaultMainWith binfo =<< liftIO getArgs
diff --git a/src/Development/Guardian/Constants.hs b/src/Development/Guardian/Constants.hs
new file mode 100644
--- /dev/null
+++ b/src/Development/Guardian/Constants.hs
@@ -0,0 +1,8 @@
+{-# LANGUAGE QuasiQuotes #-}
+
+module Development.Guardian.Constants (configFileName) where
+
+import Path (File, Path, Rel, relfile)
+
+configFileName :: Path Rel File
+configFileName = [relfile|dependency-domains.yaml|]
diff --git a/src/Development/Guardian/Graph.hs b/src/Development/Guardian/Graph.hs
new file mode 100644
--- /dev/null
+++ b/src/Development/Guardian/Graph.hs
@@ -0,0 +1,278 @@
+{-# LANGUAGE ApplicativeDo #-}
+{-# LANGUAGE BangPatterns #-}
+{-# LANGUAGE DeriveGeneric #-}
+{-# LANGUAGE DeriveTraversable #-}
+{-# LANGUAGE DerivingVia #-}
+{-# LANGUAGE FlexibleContexts #-}
+{-# LANGUAGE GADTs #-}
+{-# LANGUAGE LambdaCase #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE RecordWildCards #-}
+{-# LANGUAGE ScopedTypeVariables #-}
+{-# LANGUAGE TypeApplications #-}
+
+module Development.Guardian.Graph where
+
+import qualified Algebra.Graph as G
+import qualified Algebra.Graph.Class as GC
+import Algebra.Graph.Label (Path)
+import qualified Algebra.Graph.Labelled as LG
+import qualified Algebra.Graph.Relation as Rel
+import qualified Algebra.Graph.Relation.Preorder as Preorder
+import qualified Algebra.Graph.ToGraph as GC
+import Control.Monad (guard, void)
+import Data.Bifunctor (Bifunctor)
+import qualified Data.Bifunctor as Bi
+import Data.Coerce (coerce)
+import qualified Data.DList as DL
+import qualified Data.DList.DNonEmpty as DLNE
+import qualified Data.HashMap.Strict as HM
+import qualified Data.List.NonEmpty as NE
+import Data.Map.Strict (Map)
+import qualified Data.Map.Strict as Map
+import Data.Maybe
+import Data.Semigroup.Generic
+import Data.Set (Set)
+import qualified Data.Set as Set
+import qualified Data.Vector as V
+import Development.Guardian.Types (
+  ActualGraph,
+  CheckResult (..),
+  Dependency (..),
+  Diagnostics (Diagnostics, redundantExtraDeps, usedExceptionalRules),
+  Domain (Domain, dependsOn, packages),
+  DomainGraph,
+  DomainGraphError (..),
+  DomainInfo (..),
+  DomainName,
+  Domains (..),
+  Overlayed (Overlayed, getOverlayed),
+  PackageDef (PackageDef, extraDeps, packageName),
+  PackageDic,
+  PackageGraph,
+  PackageName,
+  PackageViolation (
+    CyclicPackageDep,
+    DomainBoundaryViolation,
+    UncoveredPackages
+  ),
+  isEmptyDiagnostics,
+ )
+import GHC.Generics (Generic)
+import Validation
+
+buildDomainInfo :: Domains -> Validation (NE.NonEmpty DomainGraphError) DomainInfo
+buildDomainInfo domainConfig = do
+  let packageDic = buildPackageDic domainConfig
+  domainGraph <- toDomainGraph domainConfig
+  pure DomainInfo {..}
+
+buildRawDomainGraph ::
+  (GC.Graph gr, GC.Vertex gr ~ DomainName) =>
+  Domains ->
+  gr
+buildRawDomainGraph Domains {..} =
+  getOverlayed $
+    HM.foldMapWithKey
+      ( \dom Domain {..} ->
+          Overlayed (GC.vertex dom)
+            <> maybe mempty (foldMap (Overlayed . GC.edge dom)) dependsOn
+      )
+      domains
+
+toDomainGraph :: Domains -> Validation (NE.NonEmpty DomainGraphError) DomainGraph
+toDomainGraph doms =
+  Bi.first DLNE.toNonEmpty $
+    ans
+      <$ Bi.first DLNE.singleton (detectCycle raw)
+      <* Bi.first DLNE.singleton (detectPackageOverlaps doms)
+  where
+    !raw = buildRawDomainGraph doms
+    !ans = Preorder.fromRelation raw
+
+detectPackageOverlaps ::
+  Domains -> Validation DomainGraphError ()
+detectPackageOverlaps Domains {..}
+  | Map.null overlaps = Success ()
+  | otherwise = Failure $ OverlappingPackages overlaps
+  where
+    overlaps =
+      Map.filter ((> 1) . Set.size) $
+        getCatMap $
+          HM.foldMapWithKey
+            ( \dom ->
+                foldMap
+                  ( CatMap
+                      . flip Map.singleton (Set.singleton dom)
+                      . packageName
+                  )
+                  . packages
+            )
+            domains
+
+newtype CatMap k v = CatMap {getCatMap :: Map k v}
+
+instance (Semigroup v, Ord k) => Semigroup (CatMap k v) where
+  (<>) = coerce $ Map.unionWith @k @v (<>)
+  {-# INLINE (<>) #-}
+
+instance (Semigroup v, Ord k) => Monoid (CatMap k v) where
+  mempty = CatMap Map.empty
+  {-# INLINE mempty #-}
+
+detectCycle ::
+  (GC.ToGraph t, Ord (GC.ToVertex t), GC.ToVertex t ~ DomainName) =>
+  t ->
+  Validation DomainGraphError ()
+detectCycle gr =
+  Bi.first CyclicDomainDep $
+    eitherToValidation $
+      void $
+        GC.topSort gr
+
+buildPackageDic :: Domains -> PackageDic
+buildPackageDic =
+  HM.foldMapWithKey
+    ( \domName Domain {..} ->
+        Map.fromList $
+          map (\PackageDef {..} -> (packageName, (domName, extraDeps))) $
+            V.toList packages
+    )
+    . domains
+
+matches :: PackageDic -> Dependency -> PackageName -> Bool
+matches pkgDic (DomainDep dn) pkg =
+  maybe False ((dn ==) . fst) $ Map.lookup pkg pkgDic
+matches _ (PackageDep pn) pkg = pn == pkg
+
+newtype LOverlayed e a = LOverlayed {getLOverlayed :: LG.Graph e a}
+  deriving (Show, Eq, Ord, Generic)
+
+instance Monoid e => Semigroup (LOverlayed e a) where
+  (<>) = coerce $ LG.overlay @e @a
+  {-# INLINE (<>) #-}
+
+instance Monoid e => Monoid (LOverlayed e a) where
+  mempty = LOverlayed LG.empty
+  {-# INLINE mempty #-}
+
+data ActualGraphs a b = AGs {activatedGraph :: a, exceptionGraph :: b}
+  deriving (Functor, Show, Eq, Ord, Generic)
+  deriving (Semigroup, Monoid) via GenericSemigroupMonoid (ActualGraphs a b)
+
+instance Bifunctor ActualGraphs where
+  bimap f g (AGs x y) = AGs (f x) (g y)
+  {-# INLINE bimap #-}
+  first f (AGs x y) = AGs (f x) y
+  {-# INLINE first #-}
+  second g (AGs x y) = AGs x (g y)
+  {-# INLINE second #-}
+
+buildActualGraphs ::
+  PackageDic ->
+  PackageGraph ->
+  ActualGraphs ActualGraph (Map PackageName (Set Dependency))
+buildActualGraphs pkgDic =
+  Bi.bimap
+    (Bi.first DL.toList . getLOverlayed)
+    (Map.filter (not . Set.null) . getCatMap)
+    . foldMap
+      ( \e@(src, dst) ->
+          let (srcDomain, srcExcept) =
+                fromMaybe (error $ "src, not found: " <> show (src, pkgDic)) $
+                  Map.lookup src pkgDic
+              (dstDomain, _) =
+                fromMaybe (error $ "dst, not found: " <> show (dst, pkgDic)) $
+                  Map.lookup dst pkgDic
+              aGraph = LOverlayed $ LG.edge (DL.singleton e) srcDomain dstDomain
+              excepts = Set.fromList $ V.toList $ V.filter (flip (matches pkgDic) dst) srcExcept
+           in if Set.null excepts
+                then AGs {exceptionGraph = mempty, activatedGraph = aGraph}
+                else AGs {activatedGraph = mempty, exceptionGraph = CatMap $ Map.singleton src excepts}
+      )
+    . G.edgeList
+
+validatePackageGraph ::
+  DomainInfo -> PackageGraph -> Validation (NE.NonEmpty PackageViolation) CheckResult
+validatePackageGraph DomainInfo {..} pg =
+  Bi.first DLNE.toNonEmpty $
+    resl
+      <$ ( case Bi.first DLNE.singleton (detectPackageCycle pg) of
+            f@Failure {} -> f
+            Success {} ->
+              Bi.first DLNE.singleton (coversAllPackages packageDic pg)
+                <* satisfiesDomainGraph domainGraph activatedGraph
+         )
+  where
+    AGs {..} = buildActualGraphs packageDic pg
+    redundantExtras = findRedundantExtraDeps packageDic pg
+    diags =
+      Diagnostics
+        { redundantExtraDeps =
+            Set.fromList . V.toList <$> redundantExtras
+        , usedExceptionalRules = exceptionGraph
+        }
+    resl
+      | isEmptyDiagnostics diags = Ok
+      | otherwise = OkWithDiagnostics diags
+
+detectPackageCycle ::
+  PackageGraph -> Validation PackageViolation ()
+detectPackageCycle pkgs =
+  void $
+    eitherToValidation $
+      Bi.first CyclicPackageDep $
+        GC.topSort pkgs
+
+type ExemptDomDeps = ActualGraph
+
+findRedundantExtraDeps ::
+  PackageDic ->
+  PackageGraph ->
+  Map PackageName (V.Vector Dependency)
+findRedundantExtraDeps pkgDic pg =
+  Map.mapMaybeWithKey
+    ( \pkg (_, specifiedDeps) -> do
+        let actualDepPkgs = GC.postSet pkg pg
+            actualDepDoms =
+              Set.map
+                (\dpkg -> maybe (error $ "No pkg find: " <> show (dpkg, actualDepPkgs)) fst (Map.lookup dpkg pkgDic))
+                actualDepPkgs
+            deps =
+              V.filter
+                ( \case
+                    DomainDep dn -> dn `Set.notMember` actualDepDoms
+                    PackageDep pn -> pn `Set.notMember` actualDepPkgs
+                )
+                specifiedDeps
+        guard $ not $ V.null deps
+        pure deps
+    )
+    pkgDic
+
+coversAllPackages ::
+  PackageDic -> PackageGraph -> Validation PackageViolation ()
+coversAllPackages pkgDic pg =
+  if null remain
+    then Success ()
+    else Failure $ UncoveredPackages remain
+  where
+    remain = Set.toList $ G.vertexSet pg Set.\\ Map.keysSet pkgDic
+
+satisfiesDomainGraph ::
+  DomainGraph -> ActualGraph -> Validation (DLNE.DNonEmpty PackageViolation) ()
+satisfiesDomainGraph domGr ag =
+  maybeToFailure () (DLNE.fromNonEmpty <$> NE.nonEmpty violatingEdges)
+  where
+    expectedEdges = Rel.edgeSet $ Preorder.toRelation domGr
+    actualEdges :: Map (DomainName, DomainName) (Path PackageName)
+    actualEdges =
+      Map.fromList $
+        map (\(x1, dn, dn') -> ((dn, dn'), x1)) $
+          LG.edgeList ag
+    violatingEdges =
+      map
+        ( uncurry $ uncurry DomainBoundaryViolation
+        )
+        $ Map.toList
+        $ actualEdges `Map.withoutKeys` expectedEdges
diff --git a/src/Development/Guardian/Graph/Adapter/Cabal.hs b/src/Development/Guardian/Graph/Adapter/Cabal.hs
new file mode 100644
--- /dev/null
+++ b/src/Development/Guardian/Graph/Adapter/Cabal.hs
@@ -0,0 +1,116 @@
+{-# LANGUAGE DeriveGeneric #-}
+{-# LANGUAGE DerivingStrategies #-}
+{-# LANGUAGE FlexibleInstances #-}
+{-# LANGUAGE LambdaCase #-}
+{-# LANGUAGE OverloadedLabels #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE RecordWildCards #-}
+{-# LANGUAGE TypeFamilies #-}
+
+module Development.Guardian.Graph.Adapter.Cabal (
+  buildPackageGraph,
+  CustomPackageOptions (..),
+  Cabal,
+) where
+
+import qualified Algebra.Graph as G
+import Control.Applicative (optional, (<|>))
+import Control.Monad (when)
+import Data.Aeson (FromJSON, withObject, (.:))
+import qualified Data.Aeson.KeyMap as AKM
+import Data.Aeson.Types (FromJSON (..))
+import Data.Either (fromLeft)
+import Data.Generics.Labels ()
+import qualified Data.Map.Strict as Map
+import Data.Maybe
+import qualified Data.Set as Set
+import Data.String (fromString)
+import Development.Guardian.Graph.Adapter.Types (ComponentsOptions (..), CustomPackageOptions, PackageGraphOptions (..))
+import Development.Guardian.Types (Overlayed (..), PackageGraph)
+import qualified Development.Guardian.Types as Guard
+import Distribution.Client.CmdUpdate (updateAction)
+import Distribution.Client.InstallPlan (GenericPlanPackage (..), depends)
+import qualified Distribution.Client.InstallPlan as Plan
+import Distribution.Client.NixStyleOptions (defaultNixStyleFlags)
+import Distribution.Client.ProjectConfig (ProjectRoot (..))
+import Distribution.Client.ProjectOrchestration (CurrentCommand (..), ProjectBaseContext (..), establishProjectBaseContextWithRoot, withInstallPlan)
+import Distribution.Client.ProjectPlanning (ElaboratedConfiguredPackage (..), elabLocalToProject)
+import Distribution.Package (Package (..), packageName, unPackageName)
+import Distribution.Simple.Flag (Flag (..))
+import Distribution.Verbosity (silent)
+import GHC.Generics (Generic)
+import Lens.Micro
+import Path
+
+data Cabal deriving (Generic)
+
+data instance CustomPackageOptions Cabal = CabalOptions {projectFile :: Maybe (Path Rel File), update :: Maybe (Either Bool String)}
+  deriving (Show, Eq, Ord, Generic)
+
+instance FromJSON (CustomPackageOptions Cabal) where
+  parseJSON = withObject "{cabal: ...}" $ \obj ->
+    if AKM.member "cabal" obj
+      then do
+        dic <- obj .: "cabal"
+        projectFile <- optional $ dic .: "projectFile"
+        update <-
+          if AKM.member "update" dic
+            then
+              fmap Just $
+                Left <$> (dic .: "update")
+                  <|> Right <$> (dic .: "update")
+            else pure Nothing
+        pure CabalOptions {..}
+      else pure (CabalOptions Nothing Nothing)
+
+buildPackageGraph :: PackageGraphOptions Cabal -> IO PackageGraph
+buildPackageGraph PackageGraphOptions {customOptions = CabalOptions {..}, ..} = do
+  let target = fromAbsDir targetPath
+      mproj = fromAbsFile . (targetPath </>) <$> projectFile
+      root = maybe (ProjectRootImplicit target) (ProjectRootExplicit target) mproj
+  when (maybe False (fromLeft True) update) $ do
+    let targets
+          | Just (Right idx) <- update = [idx]
+          | otherwise = []
+    updateAction (defaultNixStyleFlags ()) targets mempty
+
+  ctx0 <- establishProjectBaseContextWithRoot silent mempty root OtherCommand
+  let pjCfg' =
+        projectConfig ctx0
+          & #projectConfigLocalPackages . #packageConfigTests
+            .~ Flag (tests components)
+          & #projectConfigLocalPackages . #packageConfigBenchmarks
+            .~ Flag (benchmarks components)
+      -- ProjectBaseContext has no Generic instance...
+      ctx = ctx0 {projectConfig = pjCfg'}
+
+  withInstallPlan silent ctx $ \iplan _scfg -> do
+    let localPkgDic =
+          Map.mapMaybe
+            ( \case
+                Configured pkg
+                  | elabLocalToProject pkg -> pure pkg
+                Installed pkg
+                  | elabLocalToProject pkg -> pure pkg
+                _ -> Nothing
+            )
+            $ Plan.toMap iplan
+        localUnitIds = Map.keysSet localPkgDic
+        gr =
+          getOverlayed $
+            foldMap
+              ( \pkg ->
+                  let srcPkg = packageName' pkg
+                      deps =
+                        filter (/= srcPkg)
+                          $ mapMaybe
+                            (fmap packageName' . (`Map.lookup` localPkgDic))
+                          $ filter (`Set.member` localUnitIds)
+                          $ depends pkg
+                   in foldMap (Overlayed . G.edge srcPkg) deps
+              )
+              localPkgDic
+    pure gr
+
+packageName' :: Package pkg => pkg -> Guard.PackageName
+packageName' = fromString . unPackageName . packageName
diff --git a/src/Development/Guardian/Graph/Adapter/Detection.hs b/src/Development/Guardian/Graph/Adapter/Detection.hs
new file mode 100644
--- /dev/null
+++ b/src/Development/Guardian/Graph/Adapter/Detection.hs
@@ -0,0 +1,96 @@
+{-# LANGUAGE DeriveGeneric #-}
+{-# LANGUAGE LambdaCase #-}
+{-# LANGUAGE MultiWayIf #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE QuasiQuotes #-}
+
+module Development.Guardian.Graph.Adapter.Detection (
+  detectAdapter,
+  detectAdapterThrow,
+  detectFromDir,
+  detectFromDomainConfig,
+  DetectionFailure (..),
+) where
+
+import Control.Monad.Trans.Except (ExceptT (..), catchE, runExceptT, throwE)
+import Data.Aeson (Value)
+import qualified Data.Aeson as J
+import qualified Data.Aeson.KeyMap as AKM
+import Development.Guardian.Graph.Adapter.Types
+import Path (Path, relfile, (</>))
+import Path.IO
+import Path.Posix (Dir)
+import RIO
+
+data DetectionFailure
+  = BothCabalAndStackSectionsPresentInConfigYaml
+  | BothCabalProjectAndStackYamlFound
+  | NeitherCabalProjectNorStackYamlFound
+  | NoCustomConfigSpecified
+  | MalformedConfigYaml Value
+  deriving (Show, Eq, Ord, Generic)
+
+instance Exception DetectionFailure where
+  displayException BothCabalAndStackSectionsPresentInConfigYaml =
+    "Could not determine adapter: dependency-domain.yml contains both cabal and stack configuration"
+  displayException BothCabalProjectAndStackYamlFound =
+    "Could not determine adapter: Both cabal.project and stack.yaml found"
+  displayException NeitherCabalProjectNorStackYamlFound =
+    "Could not determine adapter: Neither cabal.project nor stack.yaml found"
+  displayException NoCustomConfigSpecified =
+    "Could not determine adapter: config file doesn't include neither cabal or stack"
+  displayException (MalformedConfigYaml _va) =
+    let kind = case _va of
+          J.Object {} -> "an object"
+          J.Array {} -> "an array"
+          J.String {} -> "a string"
+          J.Number {} -> "a number"
+          J.Bool {} -> "a boolean value"
+          J.Null {} -> "null"
+     in "Could not determine adapter: malformed configuration; must be object, but got: " <> kind
+
+detectAdapterThrow :: MonadIO m => Value -> Path b Dir -> m StandardAdapters
+detectAdapterThrow val = either throwIO pure <=< detectAdapter val
+
+detectAdapter :: MonadIO m => Value -> Path b Dir -> m (Either DetectionFailure StandardAdapters)
+detectAdapter config dir = runExceptT $ do
+  ExceptT (detectFromDir dir) `catchE` \case
+    NeitherCabalProjectNorStackYamlFound ->
+      ExceptT $ pure $ detectFromDomainConfig config
+    BothCabalProjectAndStackYamlFound ->
+      ExceptT $ pure $ detectFromDomainConfig config
+    exc -> throwE exc
+
+{- |
+Searching for stack.yaml and cabal.project, chooses that one if exactly one of them is found.
+-}
+detectFromDir ::
+  MonadIO m =>
+  Path b Dir ->
+  m (Either DetectionFailure StandardAdapters)
+detectFromDir dir0 = runExceptT $ do
+  dir <- canonicalizePath dir0
+  stackThere <- doesFileExist (dir </> [relfile|stack.yaml|])
+  cabalThere <- doesFileExist (dir </> [relfile|cabal.project|])
+  if
+      | stackThere && cabalThere -> throwE BothCabalProjectAndStackYamlFound
+      | stackThere -> pure Stack
+      | cabalThere -> pure Cabal
+      | otherwise -> throwE NeitherCabalProjectNorStackYamlFound
+
+{- |
+Detects backend from dependency-domains.yml.
+If exactly one of `cabal' or `stack' section is present, prefer it.
+-}
+detectFromDomainConfig :: Value -> Either DetectionFailure StandardAdapters
+detectFromDomainConfig val =
+  case val of
+    J.Object dic -> do
+      let cabalPresent = AKM.member "cabal" dic
+          stackPresent = AKM.member "stack" dic
+      if
+          | cabalPresent && stackPresent -> Left BothCabalAndStackSectionsPresentInConfigYaml
+          | cabalPresent -> Right Cabal
+          | stackPresent -> Right Stack
+          | otherwise -> Left NoCustomConfigSpecified
+    _ -> Left $ MalformedConfigYaml val
diff --git a/src/Development/Guardian/Graph/Adapter/Stack.hs b/src/Development/Guardian/Graph/Adapter/Stack.hs
new file mode 100644
--- /dev/null
+++ b/src/Development/Guardian/Graph/Adapter/Stack.hs
@@ -0,0 +1,123 @@
+{-# LANGUAGE DeriveGeneric #-}
+{-# LANGUAGE FlexibleInstances #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE RecordWildCards #-}
+{-# LANGUAGE TypeFamilies #-}
+{-# LANGUAGE ViewPatterns #-}
+
+module Development.Guardian.Graph.Adapter.Stack (
+  buildPackageGraphM,
+  buildPackageGraph,
+  Stack,
+  CustomPackageOptions (..),
+) where
+
+import qualified Algebra.Graph as G
+import Control.Applicative ((<**>))
+import Data.Aeson (FromJSON (parseJSON))
+import qualified Data.Aeson as J
+import Data.Maybe (fromMaybe)
+import Data.Set (Set)
+import qualified Data.Set as Set
+import Data.String (fromString)
+import Data.Text (Text)
+import qualified Data.Text as T
+import Development.Guardian.Graph.Adapter.Types
+import Development.Guardian.Types (Overlayed (Overlayed, getOverlayed), PackageGraph)
+import qualified Development.Guardian.Types as Guard
+import Distribution.Simple (unPackageName)
+import GHC.Generics (Generic)
+import Options.Applicative (helper)
+import qualified Options.Applicative as Opt
+import Path (fromAbsDir)
+import Path.IO (withCurrentDir)
+import Stack.Build.Source (loadLocalPackage)
+import Stack.Options.GlobalParser (globalOptsFromMonoid, globalOptsParser)
+import Stack.Options.Utils (GlobalOptsContext (OuterGlobalOpts))
+import Stack.Prelude (RIO, toList, view)
+import qualified Stack.Prelude as Stack
+import Stack.Runners (ShouldReexec (NoReexec), withConfig, withDefaultEnvConfig, withRunnerGlobal)
+import Stack.Types.Build (LocalPackage)
+import Stack.Types.Config (HasBuildConfig, HasSourceMap (sourceMapL))
+import Stack.Types.Package (LocalPackage (..), Package (..))
+import qualified Stack.Types.Package as Stack
+import Stack.Types.SourceMap (SourceMap (..))
+
+data Stack
+
+newtype instance CustomPackageOptions Stack = StackOptions {stackOptions :: [Text]}
+  deriving (Show, Eq, Ord, Generic)
+
+instance FromJSON (CustomPackageOptions Stack) where
+  parseJSON = J.withObject "{stack: }" $ \obj -> do
+    stack <- obj J..:? "stack"
+    case stack of
+      Nothing -> pure $ StackOptions []
+      Just dic -> StackOptions <$> dic J..:? "options" J..!= []
+
+localPackageToPackage :: LocalPackage -> Package
+localPackageToPackage lp =
+  fromMaybe (lpPackage lp) (lpTestBench lp)
+
+{- | Resolve the direct (depth 0) external dependencies of the given local packages (assumed to come from project packages)
+
+Stolen from @stack@ and further simplified.
+-}
+projectPackageDependencies ::
+  [LocalPackage] -> [(Stack.PackageName, Set Stack.PackageName)]
+projectPackageDependencies locals =
+  map
+    ( \lp ->
+        let pkg = localPackageToPackage lp
+         in (Stack.packageName pkg, deps pkg)
+    )
+    locals
+  where
+    deps pkg =
+      Set.intersection localNames (packageAllDeps pkg)
+    localNames = Set.fromList $ map (Stack.packageName . lpPackage) locals
+
+buildPackageGraph :: PackageGraphOptions Stack -> IO PackageGraph
+buildPackageGraph PackageGraphOptions {customOptions = StackOptions {..}, ..} = do
+  withCurrentDir targetPath $ do
+    let pInfo =
+          Opt.info
+            (globalOptsParser (fromAbsDir targetPath) OuterGlobalOpts Nothing <**> helper)
+            mempty
+        cliOpts =
+          "--skip-ghc-check"
+            : concat
+              [ ["--test", "--no-run-tests"]
+              | tests components
+              ]
+              <> concat
+                [ ["--bench", "--no-run-benchmarks"]
+                | benchmarks components
+                ]
+            ++ map T.unpack stackOptions
+    Just gopt <-
+      mapM (globalOptsFromMonoid False) $
+        Opt.getParseResult $
+          Opt.execParserPure (Opt.prefs mempty) pInfo cliOpts
+
+    withRunnerGlobal gopt $
+      withConfig NoReexec $
+        withDefaultEnvConfig buildPackageGraphM
+
+buildPackageGraphM ::
+  (HasSourceMap env, HasBuildConfig env) =>
+  RIO env PackageGraph
+buildPackageGraphM = do
+  sourceMap <- view sourceMapL
+  locals <- mapM loadLocalPackage $ toList $ smProject sourceMap
+  let gr = projectPackageDependencies locals
+  pure $
+    getOverlayed $
+      foldMap
+        ( \(fromStackPackageName -> pkg, deps) ->
+            foldMap (Overlayed . G.edge pkg . fromStackPackageName) deps
+        )
+        gr
+
+fromStackPackageName :: Stack.PackageName -> Guard.PackageName
+fromStackPackageName = fromString . unPackageName
diff --git a/src/Development/Guardian/Graph/Adapter/Types.hs b/src/Development/Guardian/Graph/Adapter/Types.hs
new file mode 100644
--- /dev/null
+++ b/src/Development/Guardian/Graph/Adapter/Types.hs
@@ -0,0 +1,75 @@
+{-# LANGUAGE DeriveGeneric #-}
+{-# LANGUAGE FlexibleContexts #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE RecordWildCards #-}
+{-# LANGUAGE StandaloneDeriving #-}
+{-# LANGUAGE TypeFamilies #-}
+{-# LANGUAGE UndecidableInstances #-}
+
+module Development.Guardian.Graph.Adapter.Types (
+  PackageBuildParser (..),
+  CustomPackageOptions,
+  PackageGraphOptions (..),
+  ComponentsOptions (..),
+  StandardAdapters (..),
+) where
+
+import Data.Aeson (FromJSON (..))
+import qualified Data.Aeson as J
+import GHC.Generics (Generic)
+import Path (Abs, Dir, Path)
+
+data StandardAdapters = Stack | Cabal
+  deriving (Show, Eq, Ord)
+
+data family CustomPackageOptions backend
+
+newtype PackageBuildParser backend = PackageBuildParser
+  { withTargetPath ::
+      Path Abs Dir ->
+      PackageGraphOptions backend
+  }
+
+instance
+  FromJSON (CustomPackageOptions backend) =>
+  FromJSON (PackageBuildParser backend)
+  where
+  parseJSON obj = do
+    customOptions <- parseJSON obj
+    components <- parseJSON obj
+    pure $ PackageBuildParser $ \targetPath -> PackageGraphOptions {..}
+
+data PackageGraphOptions backend = PackageGraphOptions
+  { targetPath :: !(Path Abs Dir)
+  , components :: !ComponentsOptions
+  , customOptions :: CustomPackageOptions backend
+  }
+  deriving (Generic)
+
+deriving instance
+  Show (CustomPackageOptions backend) =>
+  Show (PackageGraphOptions backend)
+
+deriving instance
+  Eq (CustomPackageOptions backend) =>
+  Eq (PackageGraphOptions backend)
+
+deriving instance
+  Ord (CustomPackageOptions backend) =>
+  Ord (PackageGraphOptions backend)
+
+data ComponentsOptions = ComponentsOptions
+  { tests :: Bool
+  , benchmarks :: Bool
+  }
+  deriving (Show, Eq, Ord, Generic)
+
+instance FromJSON ComponentsOptions where
+  parseJSON = J.withObject "{components: ...}" $ \dic -> do
+    mcomp <- dic J..:? "components"
+    case mcomp of
+      Nothing -> pure ComponentsOptions {tests = True, benchmarks = True}
+      Just comps -> do
+        tests <- comps J..: "tests" J..!= True
+        benchmarks <- comps J..: "benchmarks" J..!= True
+        pure ComponentsOptions {..}
diff --git a/src/Development/Guardian/Types.hs b/src/Development/Guardian/Types.hs
new file mode 100644
--- /dev/null
+++ b/src/Development/Guardian/Types.hs
@@ -0,0 +1,155 @@
+{-# LANGUAGE BlockArguments #-}
+{-# LANGUAGE DeriveAnyClass #-}
+{-# LANGUAGE DeriveGeneric #-}
+{-# LANGUAGE DerivingStrategies #-}
+{-# LANGUAGE GeneralizedNewtypeDeriving #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE RecordWildCards #-}
+{-# LANGUAGE ScopedTypeVariables #-}
+{-# LANGUAGE TypeApplications #-}
+
+module Development.Guardian.Types where
+
+import Algebra.Graph (Graph)
+import Algebra.Graph.AdjacencyMap.Algorithm (Cycle)
+import qualified Algebra.Graph.Class as GC
+import Algebra.Graph.Label
+import qualified Algebra.Graph.Labelled as L
+import Algebra.Graph.Relation.Preorder (PreorderRelation)
+import Control.Applicative
+import Control.Exception (Exception)
+import Control.Monad.Trans.Reader (ReaderT (ReaderT, runReaderT))
+import Data.Aeson (FromJSON (..), FromJSONKey, genericParseJSON, withObject, withText, (.:), (.:?))
+import qualified Data.Aeson as J
+import Data.Coerce (coerce)
+import qualified Data.HashMap.Strict as HM
+import Data.Hashable (Hashable)
+import Data.Map (Map)
+import qualified Data.Map.Strict as Map
+import Data.Set (Set)
+import Data.String (IsString)
+import Data.Text (Text)
+import qualified Data.Vector as V
+import GHC.Generics (Generic)
+
+type DomainGraph = PreorderRelation DomainName
+
+type PackageGraph = Graph PackageName
+
+type ActualGraph' e = L.Graph e DomainName
+
+type ActualGraph = ActualGraph' (Path PackageName)
+
+type PackageDic = Map PackageName (DomainName, V.Vector Dependency)
+
+data DomainGraphError
+  = CyclicDomainDep (Cycle DomainName)
+  | OverlappingPackages (Map PackageName (Set DomainName))
+  deriving (Show, Eq, Ord, Generic)
+  deriving anyclass (Exception)
+
+data CheckResult
+  = Ok
+  | OkWithDiagnostics Diagnostics
+  deriving (Show, Eq, Ord, Generic)
+
+isEmptyDiagnostics :: Diagnostics -> Bool
+isEmptyDiagnostics Diagnostics {..} =
+  Map.null redundantExtraDeps
+    -- && Set.null redundantDomainDeps
+    && Map.null usedExceptionalRules
+
+data Diagnostics = Diagnostics
+  { redundantExtraDeps :: Map PackageName (Set Dependency)
+  , -- , redundantDomainDeps :: Set (DomainName, DomainName)
+    usedExceptionalRules :: Map PackageName (Set Dependency)
+  }
+  deriving (Show, Eq, Ord, Generic)
+
+data DomainInfo = DomainInfo
+  { domainConfig :: Domains
+  , domainGraph :: DomainGraph
+  , packageDic :: PackageDic
+  }
+  deriving (Show, Eq, Ord, Generic)
+
+data PackageViolation
+  = DomainBoundaryViolation
+      { fromDom :: DomainName
+      , toDom :: DomainName
+      , introducedBy :: [(PackageName, PackageName)]
+      }
+  | CyclicPackageDep (Cycle PackageName)
+  | UncoveredPackages [PackageName]
+  deriving (Show, Eq, Ord, Generic)
+  deriving anyclass (Exception)
+
+newtype PackageName = PackageName {getPackageName :: Text}
+  deriving (Eq, Ord, Generic)
+  deriving newtype (Show, IsString, FromJSON, Hashable)
+
+newtype DomainName = DomainName {getDomainName :: Text}
+  deriving (Eq, Ord, Generic)
+  deriving newtype (Show, IsString, FromJSON, FromJSONKey, Hashable)
+
+data Domain = Domain
+  { dependsOn :: Maybe (V.Vector DomainName)
+  , packages :: V.Vector PackageDef
+  }
+  deriving (Show, Eq, Ord, Generic)
+
+instance FromJSON Domain where
+  parseJSON =
+    genericParseJSON
+      J.defaultOptions
+        { J.omitNothingFields = True
+        , J.fieldLabelModifier = J.camelTo2 '_'
+        }
+
+newtype Domains = Domains {domains :: HM.HashMap DomainName Domain}
+  deriving (Show, Eq, Ord, Generic)
+  deriving anyclass (FromJSON)
+
+data PackageDef = PackageDef
+  { packageName :: !PackageName
+  , extraDeps :: V.Vector Dependency
+  }
+  deriving (Show, Eq, Ord, Generic)
+
+instance FromJSON PackageDef where
+  parseJSON =
+    runReaderT $
+      ReaderT (withText "package name" (pure . (`PackageDef` V.empty) . PackageName))
+        <|> ReaderT do
+          withObject "object" $ \dic -> do
+            packageName <- dic .: "package"
+            excepts <- dic .:? "exception"
+            extraDeps <- maybe (pure V.empty) (.: "depends_on") excepts
+            pure PackageDef {..}
+
+data Dependency
+  = DomainDep !DomainName
+  | PackageDep !PackageName
+  deriving (Show, Eq, Ord, Generic)
+
+instance FromJSON Dependency where
+  parseJSON =
+    runReaderT $
+      ReaderT (withText "domain name" (pure . DomainDep . DomainName))
+        <|> ReaderT
+          ( withObject
+              "{package: ...} or {domain: ...}"
+              ( \obj ->
+                  DomainDep <$> obj .: "domain"
+                    <|> PackageDep <$> obj .: "package"
+              )
+          )
+
+newtype Overlayed gr = Overlayed {getOverlayed :: gr}
+  deriving (Show, Eq, Ord)
+
+instance GC.Graph gr => Semigroup (Overlayed gr) where
+  (<>) = coerce $ GC.overlay @gr
+
+instance GC.Graph gr => Monoid (Overlayed gr) where
+  mempty = Overlayed GC.empty
diff --git a/test/Development/Guardian/AppSpec.hs b/test/Development/Guardian/AppSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Development/Guardian/AppSpec.hs
@@ -0,0 +1,178 @@
+{-# LANGUAGE BlockArguments #-}
+{-# LANGUAGE LambdaCase #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE QuasiQuotes #-}
+{-# LANGUAGE TupleSections #-}
+
+module Development.Guardian.AppSpec (test_defaultMainWith) where
+
+import Control.Exception
+import Control.Monad (void)
+import qualified Data.ByteString.Builder as BB
+import qualified Data.Text.Lazy as LT
+import qualified Data.Text.Lazy.Encoding as LT
+import Data.Version (showVersion)
+import Development.Guardian.App
+import Development.Guardian.Graph.Adapter.Detection
+import GHC.IO.Exception (ExitCode (..))
+import Path
+import Path.IO
+import Paths_guardian (version)
+import RIO (logOptionsMemory, mkSimpleApp, readIORef, runRIO, withLogFunc)
+import qualified RIO.ByteString.Lazy as LBS
+import Test.Tasty
+import Test.Tasty.HUnit
+
+fakeBuildInfo :: BuildInfo
+fakeBuildInfo = BuildInfo {versionString = showVersion version, gitInfo = Nothing}
+
+test_defaultMainWith :: TestTree
+test_defaultMainWith =
+  testGroup "defaultMainWith" $
+    concreteAdapterTests : stackCases ++ cabalCases ++ autoCases
+
+stackCases :: [TestTree]
+stackCases =
+  [ testGroup
+      "stack-specific options"
+      [ testCase "Respects --stack-yaml"
+          $ withCurrentDir
+            ([reldir|data|] </> [reldir|test-only-dependency|])
+          $ successfully_
+          $ mainWith ["cabal", "-c", "dependency-domains-custom-stack.yaml"]
+      ]
+  ]
+
+cabalCases :: [TestTree]
+cabalCases =
+  [ testGroup
+      "cabal-specific options"
+      [ testCase "Respects projectFile"
+          $ withCurrentDir
+            ([reldir|data|] </> [reldir|test-only-dependency|])
+          $ successfully_
+          $ mainWith ["cabal", "-c", "dependency-domains-custom-cabal.yaml"]
+      , testCase "Respects update: true"
+          $ withCurrentDir
+            ([reldir|data|] </> [reldir|test-only-dependency|])
+          $ successfully_
+          $ mainWith ["cabal", "-c", "dependency-domains-cabal-update-true.yaml"]
+      , testCase "Respects update: (index-state)"
+          $ withCurrentDir
+            ([reldir|data|] </> [reldir|test-only-dependency|])
+          $ successfully_
+          $ mainWith ["cabal", "-c", "dependency-domains-cabal-update-index.yaml"]
+      ]
+  ]
+
+autoCases :: [TestTree]
+autoCases =
+  [ testGroup
+      "Auto detection"
+      [ testCase "Accepts config with cabal section only"
+          $ withCurrentDir
+            ([reldir|data|] </> [reldir|test-only-dependency|])
+          $ successfully
+            (LT.isInfixOf "with backend Cabal" . LT.decodeUtf8)
+          $ mainWith ["auto", "-c", "dependency-domains-custom-cabal.yaml"]
+      , testCase "Accepts config with stack section only"
+          $ withCurrentDir
+            ([reldir|data|] </> [reldir|test-only-dependency|])
+          $ successfully
+            (LT.isInfixOf "with backend Stack" . LT.decodeUtf8)
+          $ mainWith ["auto", "-c", "dependency-domains-custom-stack.yaml"]
+      , testCase "Accepts unambiguous directory (cabal)"
+          $ withCurrentDir
+            ([reldir|data|] </> [reldir|only-cabal|])
+          $ successfully
+            (LT.isInfixOf "with backend Cabal" . LT.decodeUtf8)
+          $ mainWith ["auto"]
+      , testCase "Accepts unambiguous directory (stack)"
+          $ withCurrentDir
+            ([reldir|data|] </> [reldir|only-stack|])
+          $ successfully
+            (LT.isInfixOf "with backend Stack" . LT.decodeUtf8)
+          $ mainWith ["auto"]
+      , testCaseSteps "Rejects ambiguous inputs" \step ->
+          withCurrentDir
+            ([reldir|data|] </> [reldir|test-only-dependency|])
+            $ do
+              step "Abmiguous Directory & Config without any custom section"
+              mainWith ["auto"] `shouldThrow` (== NoCustomConfigSpecified)
+              step "Abmiguous Directory & Config wit both custom sections"
+              mainWith ["auto", "-c", "dependency-domains-ambiguous.yaml"]
+                `shouldThrow` (== BothCabalAndStackSectionsPresentInConfigYaml)
+      ]
+  ]
+
+concreteAdapterTests :: TestTree
+concreteAdapterTests =
+  testGroup
+    "Concrete adapter behaviours, independent of adapters"
+    [ testGroup
+      backend
+      [ testCase "invalidates test-only-dependency with default config"
+          $ withCurrentDir
+            ([reldir|data|] </> [reldir|test-only-dependency|])
+          $ mainWith [backend] `shouldThrow` (== ExitFailure 1)
+      , testCaseSteps "invalidates test-only-dependency with default config (explicit path argument)" \step -> do
+          step "Absolute dir"
+          dir <- canonicalizePath ([reldir|data|] </> [reldir|test-only-dependency|])
+          mainWith [backend, fromAbsDir dir] `shouldThrow` (== ExitFailure 1)
+          step "Relative dir"
+          let rdir = [reldir|data|] </> [reldir|test-only-dependency|]
+          mainWith [backend, fromRelDir rdir] `shouldThrow` (== ExitFailure 1)
+      , testCaseSteps "accepts non-standard config yaml" $ \step ->
+          withCurrentDir ([reldir|data|] </> [reldir|test-only-dependency|]) $ do
+            step "Accepts  when tests and benchmarks disabled"
+            successfully_ $
+              mainWith [backend, "-c", "dependency-domains-no-tests-benchmarks.yaml"]
+            step "Accepts input with exception rule"
+            successfully (LT.isInfixOf "exceptional rules are used" . LT.decodeUtf8) $
+              mainWith [backend, "-c", "dependency-domains-except-A2-B1.yaml"]
+      ]
+    | backend <- ["cabal", "stack"]
+    ]
+
+successfully_ :: HasCallStack => IO (LBS.ByteString, Maybe SomeException) -> IO ()
+successfully_ = void . successfully (const True)
+
+successfully :: HasCallStack => (LBS.ByteString -> Bool) -> IO (LBS.ByteString, Maybe SomeException) -> IO ()
+successfully isOk act = do
+  (a, exc) <- act
+  case exc of
+    Just err ->
+      assertFailure $
+        "Exception: "
+          <> displayException err
+          <> "\nwith log: \n"
+          <> LT.unpack (LT.decodeUtf8 a)
+    Nothing
+      | isOk a -> pure ()
+      | otherwise ->
+          assertFailure $
+            "Exits Successfully, but the output is invalid: \n"
+              <> LT.unpack (LT.decodeUtf8 a)
+
+shouldThrow :: (HasCallStack, Exception e) => IO (a, Maybe e) -> (e -> Bool) -> Assertion
+shouldThrow act p =
+  act >>= \case
+    (_, Nothing) ->
+      assertFailure
+        "Expected to throw excetpion, but exists successfully"
+    (_, Just err)
+      | p err -> pure ()
+      | otherwise ->
+          assertFailure $
+            "Exception has been thrown, but does not satisfy the requirement: "
+              <> show err
+
+mainWith :: Exception exc => [String] -> IO (LBS.ByteString, Maybe exc)
+mainWith args = do
+  (logs, opts) <- logOptionsMemory
+  eith <- try $ withLogFunc opts $ \logFunc -> do
+    app <- mkSimpleApp logFunc Nothing
+    runRIO app $
+      defaultMainWith fakeBuildInfo args
+  (,either Just (const Nothing) eith) . BB.toLazyByteString
+    <$> readIORef logs
diff --git a/test/Development/Guardian/Graph/Adapter/CabalSpec.hs b/test/Development/Guardian/Graph/Adapter/CabalSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Development/Guardian/Graph/Adapter/CabalSpec.hs
@@ -0,0 +1,63 @@
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE QuasiQuotes #-}
+{-# LANGUAGE RecordWildCards #-}
+
+module Development.Guardian.Graph.Adapter.CabalSpec (test_buildPackageGraph) where
+
+import qualified Algebra.Graph as G
+import Development.Guardian.Graph.Adapter.Cabal
+import qualified Development.Guardian.Graph.Adapter.Cabal as Cabal
+import Development.Guardian.Graph.Adapter.TestUtils
+import Development.Guardian.Graph.Adapter.Types
+import Path
+import Test.Tasty
+
+onlyExes :: ComponentsOptions
+onlyExes = ComponentsOptions {tests = False, benchmarks = False}
+
+test_buildPackageGraph :: TestTree
+test_buildPackageGraph =
+  testGroup
+    "buildPackageGraph"
+    $ map
+      testCabalProject
+      [ Case
+          { caseDir = [reldir|test-only-dependency|]
+          , expectedGraph = G.edges [("A1", "C"), ("A2", "C"), ("B1", "C")]
+          , componentOpts = onlyExes
+          , customOpts = CabalOptions Nothing Nothing
+          }
+      , Case
+          { caseDir = [reldir|test-only-dependency|]
+          , expectedGraph = G.edges [("A1", "C"), ("A2", "C"), ("A2", "B1"), ("B1", "C")]
+          , componentOpts = onlyExes {tests = True}
+          , customOpts = CabalOptions Nothing Nothing
+          }
+      , Case
+          { caseDir = [reldir|test-only-dependency|]
+          , expectedGraph = G.edges [("A1", "C"), ("A1", "A2"), ("A2", "C"), ("B1", "C")]
+          , componentOpts = onlyExes {benchmarks = True}
+          , customOpts = CabalOptions Nothing Nothing
+          }
+      , Case
+          { caseDir = [reldir|test-only-dependency|]
+          , expectedGraph = G.edges [("A1", "C"), ("A1", "A2"), ("A2", "C"), ("A2", "B1"), ("B1", "C")]
+          , componentOpts = onlyExes {tests = True, benchmarks = True}
+          , customOpts = CabalOptions Nothing Nothing
+          }
+      ]
+      ++ [ testCabalProjectWithLabel
+            "test-only-dependency (with cabal-no-A1.yaml)"
+            Case
+              { caseDir = [reldir|test-only-dependency|]
+              , expectedGraph = G.edges [("A2", "C"), ("B1", "C")]
+              , componentOpts = onlyExes
+              , customOpts = CabalOptions (Just [relfile|cabal-no-A1.project|]) Nothing
+              }
+         ]
+
+testCabalProjectWithLabel :: String -> Case Cabal -> TestTree
+testCabalProjectWithLabel = testProjectWithLabel Cabal.buildPackageGraph
+
+testCabalProject :: Case Cabal -> TestTree
+testCabalProject = testProjectWith Cabal.buildPackageGraph
diff --git a/test/Development/Guardian/Graph/Adapter/StackSpec.hs b/test/Development/Guardian/Graph/Adapter/StackSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Development/Guardian/Graph/Adapter/StackSpec.hs
@@ -0,0 +1,63 @@
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE QuasiQuotes #-}
+{-# LANGUAGE RecordWildCards #-}
+
+module Development.Guardian.Graph.Adapter.StackSpec where
+
+import qualified Algebra.Graph as G
+import Development.Guardian.Graph.Adapter.Stack
+import qualified Development.Guardian.Graph.Adapter.Stack as Stack
+import Development.Guardian.Graph.Adapter.TestUtils
+import Development.Guardian.Graph.Adapter.Types
+import Path
+import Test.Tasty
+
+onlyExes :: ComponentsOptions
+onlyExes = ComponentsOptions {tests = False, benchmarks = False}
+
+test_buildPackageGraph :: TestTree
+test_buildPackageGraph =
+  testGroup
+    "buildPackageGraph"
+    $ map
+      testStackProject
+      [ Case
+          { caseDir = [reldir|test-only-dependency|]
+          , expectedGraph = G.edges [("A1", "C"), ("A2", "C"), ("B1", "C")]
+          , componentOpts = onlyExes
+          , customOpts = StackOptions ["--silent"]
+          }
+      , Case
+          { caseDir = [reldir|test-only-dependency|]
+          , expectedGraph = G.edges [("A1", "C"), ("A2", "C"), ("A2", "B1"), ("B1", "C")]
+          , componentOpts = onlyExes {tests = True}
+          , customOpts = StackOptions ["--silent"]
+          }
+      , Case
+          { caseDir = [reldir|test-only-dependency|]
+          , expectedGraph = G.edges [("A1", "C"), ("A1", "A2"), ("A2", "C"), ("B1", "C")]
+          , componentOpts = onlyExes {benchmarks = True}
+          , customOpts = StackOptions ["--silent"]
+          }
+      , Case
+          { caseDir = [reldir|test-only-dependency|]
+          , expectedGraph = G.edges [("A1", "C"), ("A1", "A2"), ("A2", "C"), ("A2", "B1"), ("B1", "C")]
+          , componentOpts = onlyExes {tests = True, benchmarks = True}
+          , customOpts = StackOptions ["--silent"]
+          }
+      ]
+      ++ [ testStackProjectWithLabel
+            "test-only-dependency (with stack-no-A1.yaml)"
+            Case
+              { caseDir = [reldir|test-only-dependency|]
+              , expectedGraph = G.edges [("A2", "C"), ("B1", "C")]
+              , componentOpts = onlyExes
+              , customOpts = StackOptions ["--stack-yaml=stack-no-A1.yaml", "--silent"]
+              }
+         ]
+
+testStackProjectWithLabel :: String -> Case Stack -> TestTree
+testStackProjectWithLabel = testProjectWithLabel Stack.buildPackageGraph
+
+testStackProject :: Case Stack -> TestTree
+testStackProject = testProjectWith Stack.buildPackageGraph
diff --git a/test/Development/Guardian/Graph/Adapter/TestUtils.hs b/test/Development/Guardian/Graph/Adapter/TestUtils.hs
new file mode 100644
--- /dev/null
+++ b/test/Development/Guardian/Graph/Adapter/TestUtils.hs
@@ -0,0 +1,62 @@
+{-# LANGUAGE FlexibleContexts #-}
+{-# LANGUAGE QuasiQuotes #-}
+{-# LANGUAGE RecordWildCards #-}
+{-# LANGUAGE StandaloneDeriving #-}
+{-# LANGUAGE UndecidableInstances #-}
+
+module Development.Guardian.Graph.Adapter.TestUtils (
+  Case (..),
+  testProjectWith,
+  testProjectWithLabel,
+) where
+
+import Development.Guardian.Graph.Adapter.Types
+import Development.Guardian.Types
+import Path
+import Path.IO
+import Test.Tasty (TestTree)
+import Test.Tasty.HUnit (testCase, (@?=))
+
+data Case adapter = Case
+  { caseDir :: Path Rel Dir
+  , expectedGraph :: PackageGraph
+  , componentOpts :: ComponentsOptions
+  , customOpts :: CustomPackageOptions adapter
+  }
+
+deriving instance Eq (CustomPackageOptions adapter) => Eq (Case adapter)
+
+deriving instance Ord (CustomPackageOptions adapter) => Ord (Case adapter)
+
+deriving instance Show (CustomPackageOptions adapter) => Show (Case adapter)
+
+testProjectWith ::
+  (PackageGraphOptions adapter -> IO PackageGraph) ->
+  Case adapter ->
+  TestTree
+testProjectWith build = testProjectWithLabel build <$> fromRelDir . caseDir <*> id
+
+testProjectWithLabel ::
+  (PackageGraphOptions adapter -> IO PackageGraph) ->
+  String ->
+  Case adapter ->
+  TestTree
+testProjectWithLabel buildGraph label Case {..} = testCase name $ do
+  targetPath <- canonicalizePath $ [reldir|data|] </> caseDir
+
+  let components = componentOpts
+      customOptions = customOpts
+  graph <- buildGraph PackageGraphOptions {..}
+  graph @?= expectedGraph
+  where
+    ComponentsOptions {..} = componentOpts
+    sgn True = "+"
+    sgn False = "-"
+    name =
+      label
+        <> " ("
+        <> sgn tests
+        <> "test, "
+        <> sgn benchmarks
+        <> "bench"
+        <> ")"
diff --git a/test/Development/Guardian/GraphSpec.hs b/test/Development/Guardian/GraphSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Development/Guardian/GraphSpec.hs
@@ -0,0 +1,137 @@
+{-# LANGUAGE OverloadedLists #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# OPTIONS_GHC -Wno-type-defaults #-}
+
+module Development.Guardian.GraphSpec where
+
+import qualified Algebra.Graph as G
+import qualified Algebra.Graph.ToGraph as AM
+import qualified Data.HashMap.Strict as HM
+import Data.List.NonEmpty (NonEmpty ((:|)))
+import qualified Data.List.NonEmpty as NE
+import qualified Data.Map.Strict as Map
+import Development.Guardian.Graph
+import Development.Guardian.Types
+import qualified GHC.OldList as OL
+import Test.Tasty
+import Test.Tasty.HUnit
+import Validation
+
+{- |
+@
+    A1--+      +--B1
+    |   |      |
+    |   v      v
+    |  A2 --> B2
+    v         |
+    A3        +-->B3
+    |             |
+    |             |
+    +---> C1 <----+
+@
+-}
+packages1 :: PackageGraph
+packages1 = G.edges [(a1, a3), (a1, a2), (a2, b2), (b1, b2), (b2, b3), (a3, c1), (b3, c1)]
+  where
+    (a1, a2, a3) = ("A1", "A2", "A3")
+    (b1, b2, b3) = ("B1", "B2", "B3")
+    c1 = "C1"
+
+{- |
+@     A           B
+  +-------+  +-------+
+  | A1    |<-+----B1 |
+  |    A2-+--+->B2   |
+  | A3    |  |    B3 |
+  +---+---+  +---+---+
+      |          |
+      v     C    v
+  +---+----------+----+
+  |         C1        |
+  |                   |
+  +-------------------+
+@
+-}
+domains1 :: Domains
+domains1 =
+  Domains $
+    HM.fromList
+      [ ("A", Domain {dependsOn = Just ["C"], packages = [a1, a2, a3]})
+      , ("B", Domain {dependsOn = Just ["C"], packages = [b1, b2, b3]})
+      , ("C", Domain {dependsOn = Nothing, packages = [c1]})
+      ]
+  where
+    a1 = PackageDef {packageName = "A1", extraDeps = []}
+    a2 = PackageDef {packageName = "A2", extraDeps = [PackageDep "B2"]}
+    a3 = PackageDef {packageName = "A3", extraDeps = []}
+    b1 = PackageDef {packageName = "B1", extraDeps = []}
+    b2 = PackageDef {packageName = "B2", extraDeps = [DomainDep "A"]}
+    b3 = PackageDef {packageName = "B3", extraDeps = []}
+    c1 = PackageDef {packageName = "C1", extraDeps = []}
+
+test_buildDomainInfo :: TestTree
+test_buildDomainInfo =
+  testGroup
+    "buildDomainInfo"
+    [ testCase "domains1 is valid" $ do
+        let vinfo = buildDomainInfo domains1
+        isSuccess vinfo @? "Domains #1 should be valid, but got: " <> show vinfo
+    ]
+
+test_detectPackageCycle :: TestTree
+test_detectPackageCycle =
+  testGroup
+    "detectPackageCycle"
+    [ testCase "validates packages1" $
+        detectPackageCycle packages1 @?= Success ()
+    , testCase "invalidates (packages1 + A2 -> A1)" $
+        detectPackageCycle (packages1 `G.overlay` G.edge "A2" "A1")
+          @?= Failure (CyclicPackageDep ("A2" :| ["A1"]))
+    ]
+
+test_validatePackageGraph :: TestTree
+test_validatePackageGraph =
+  testGroup
+    "validatePackageGraph"
+    [ testCase "packages1 is valid, with B2 -> (A) redundant" $ do
+        doms <- fromDomainInfo $ buildDomainInfo domains1
+        case validatePackageGraph doms packages1 of
+          f@Failure {} -> assertFailure $ "Must success, but got: " <> show f
+          Success (OkWithDiagnostics Diagnostics {usedExceptionalRules = useds, redundantExtraDeps = reds})
+            | [("B2", [DomainDep "A"])] <- Map.toList reds
+            , [("A2", [PackageDep "B2"])] <- useds ->
+                pure ()
+          s@Success {} -> assertFailure $ "B2 -> (A) must be reported redundant, but got: " <> show s
+    , testCase "(packages1 + A2 -> B1) is invalid" $ do
+        doms <- fromDomainInfo $ buildDomainInfo domains1
+        let pg = packages1 `G.overlay` G.edge "A2" "B1"
+        case validatePackageGraph doms pg of
+          Failure
+            ( DomainBoundaryViolation
+                { fromDom = "A"
+                , toDom = "B"
+                , introducedBy = [("A2", "B1")]
+                }
+                :| []
+              ) -> pure ()
+          f ->
+            assertFailure $
+              "Must fail reporting A2 -> B1 as invalid, but got: "
+                <> show f
+    , testCase ("detects package cycle in " <> show (AM.toAdjacencyMap $ packages1 `G.overlay` G.edge "A2" "A1")) $ do
+        doms <- fromDomainInfo $ buildDomainInfo domains1
+        let pg = packages1 `G.overlay` G.edge "A2" "A1"
+        case validatePackageGraph doms pg of
+          Failure (CyclicPackageDep cyc :| [])
+            | cyc `OL.elem` ["A2" :| ["A1"], "A1" :| ["A2"]] -> pure ()
+          f ->
+            assertFailure $
+              "Must fail reporting A2 -> A1 -> A2 as valid, but got: "
+                <> show f
+    ]
+
+fromDomainInfo :: Validation (NonEmpty DomainGraphError) DomainInfo -> IO DomainInfo
+fromDomainInfo =
+  validation
+    (assertFailure . ("Invalid Domain: " <>) . show . NE.toList)
+    pure
diff --git a/test/Spec.hs b/test/Spec.hs
new file mode 100644
--- /dev/null
+++ b/test/Spec.hs
@@ -0,0 +1,1 @@
+{-# OPTIONS_GHC -F -pgmF tasty-discover -optF --tree-display #-}
