Skip to content

Repository files navigation

ifaceguard

A Go linter that checks architectural properties of interface usage.

Purpose

ifaceguard enforces two key architectural principles:

  • Dependency Inversion Principle (DIP): Interface contracts should belong to the consumer side (or a dedicated contract package), not the implementation side.
  • Explicit compile-time assertions: Implementation packages must declare compile-time assertions verifying interface compliance.
flowchart LR
  Consumer[Consumer / Contract]
  Interface[Interface]
  Impl[Implementation]
  Assertion[Compile-time assertion]

  Consumer -->|declares| Interface
  Consumer -->|depends on| Interface
  Impl -->|implements| Interface
  Impl -->|declares| Assertion
Loading

How the two checks differ

ifaceguard runs two independent checks that target different problems:

  • Ownership check (IFG001-OWNERSHIP): answers the question “Is the interface declared in the right package?”. It reports when a contractual interface (as defined by ownership.contractscope) is declared in the same package that also declares a type implementing it, unless the package is explicitly allowed (for example, ownership.contractpackages).

  • Assertions check (IFG002-ASSERTION-PLACEMENT / IFG003-ASSERTION-MISSING / IFG004-ASSERTION-BYPASS): answers the question “Is the compile-time assertion placed (or present) in the right package?”. It reports an assertion placed outside the implementation package, optionally reports a missing assertion when assertions.requireassertions=true, and reports bypass forms (anonymous interface or private interface used only for assertion) when requireassertions=true and checkbypassassertions=true.

These checks can be enabled or disabled independently via ownership.enabled and assertions.enabled.

Project Status

⚠️ ifaceguard is in early development. The core analysis logic is implemented and tested, but the configuration and CLI are still evolving. Breaking changes to config keys and behavior are expected until the first stable release.

Installation

go install github.com/n-r-w/ifaceguard/cmd/ifaceguard@latest

Homebrew

brew install n-r-w/homebrew-tap/ifaceguard

You can also tap first and install by formula name:

brew tap n-r-w/tap
brew install ifaceguard

From releases

Prebuilt artifacts are published on the GitHub Releases page.

Release assets include:

  • Standalone ifaceguard binary, named ifaceguard_<version>_<os>_<arch>.
  • Custom golangci-lint binary built with the plugin, named custom-gcl_<os>_<arch> (Windows adds .exe).

Choose the asset that matches your OS/arch, extract it if needed, and place the binary on your PATH. On macOS, if the system blocks the binary, open System Settings > Privacy & Security and allow it, then run again.

Usage

Standalone

ifaceguard [flags] [packages]

packages is an optional list of Go package patterns (same syntax as go list, e.g. ./..., ./internal/...).

Standalone runs accept an optional config file:

ifaceguard -config /path/to/ifaceguard.yml [packages]

If no packages are provided, the default is ./....

If -config is omitted, ifaceguard uses the default settings.

The config file uses the same settings keys as the golangci-lint example below, but at the top level:

ownership:
  enabled: true
  contractscope: exportedoutput
  skipifusedasinput: true
  contractpackages: []
  ignoreinterfaces: []
  ignoremarkerinterfaces: true
assertions:
  enabled: true
  wiringpackages: []
  acceptconversiononlyform: false
  checkbypassassertions: true
  scanfunctionbodies: false
  requireassertions: false
  requireassertionsstrict: false
constructors:
  enabled: true
  namepatterns:
    - "^New[A-Z]"
    - "^MustNew[A-Z]"
  exportedonly: true
  ignoreinterfaces:
    # Matched against full interface name: pkgpath.Interface
    # Example actual name: github.com/redis/go-redis/v9.UniversalClient
    # Prefer exact masks to avoid overmatching:
    - "^github\\.com/redis/go-redis/v9\\.UniversalClient$"
  ignoreerrorreturn: true
exclude:
  # Regexes applied to full file paths
  files:
    - ".*_mock\\.go$"
  # Regexes applied to full type names: pkgpath.TypeName
  types:
    - ".*\\.Mock.*$"

golangci-lint (module plugin)

For local development (plugin not integrated into official golangci-lint):

Create .custom-gcl.yml in your project root:

plugins:
  - module: 'github.com/n-r-w/ifaceguard'
    import: 'github.com/n-r-w/ifaceguard/pkg/golangci'
    path: /absolute/path/to/ifaceguard  # Use absolute path

Note: in this repository, use task build to build both the standalone binary and the custom golangci-lint binary. The Taskfile uses .golangci-version and the repo's build/.custom-gcl.yml.

For published plugin (after release):

plugins:
  - module: 'github.com/n-r-w/ifaceguard'
    import: 'github.com/n-r-w/ifaceguard/pkg/golangci'
    version: latest

Build the custom golangci-lint binary:

task build

Then enable in your .golangci.yml:

linters:
  enable:
    - ifaceguard

settings:
  custom:
    ifaceguard:
      type: module

      # Example configuration
      settings:
        ownership:
          enabled: true
          contractscope: exportedoutput
          skipifusedasinput: true
          contractpackages:
            - "^example\\.com/project/(contract|port)(/|$)"
          ignoreinterfaces:
            - ".*\\.Error$"
          ignoremarkerinterfaces: true
        assertions:
          enabled: true
          wiringpackages:
            - "^example\\.com/project/(cmd|internal/compose)(/|$)"
          acceptconversiononlyform: false
          checkbypassassertions: true
          scanfunctionbodies: false
          requireassertions: false
          requireassertionsstrict: false
        constructors:
          enabled: true
          namepatterns:
            - "^New[A-Z]"
            - "^MustNew[A-Z]"
          exportedonly: true
          ignoreinterfaces:
            # Matched against full interface name: pkgpath.Interface
            - "^example\\.com/project/contract\\.Allowed$"
            # go-redis example (exact match):
            - "^github\\.com/redis/go-redis/v9\\.UniversalClient$"
          ignoreerrorreturn: true
        exclude:
          # Regexes applied to full file paths
          files:
            - ".*_mock\\.go$"
          # Regexes applied to full type names: pkgpath.TypeName
          types:
            - ".*\\.Mock.*$"

Configuration options

Regex mask guide (applies to all list[regex] settings)

ifaceguard uses Go regular expressions (RE2 syntax).

Each key matches a different target value:

Setting Matched value
ownership.contractpackages package import path
ownership.ignoreinterfaces full interface name: pkgpath.Interface
assertions.wiringpackages package import path
constructors.namepatterns package-level function name
constructors.ignoreinterfaces full interface name: pkgpath.Interface
exclude.files full file path (with /)
exclude.types full type name: pkgpath.TypeName

Practical rules:

  • Prefer exact masks with ^...$ to avoid accidental matches.
  • Escape only literal dots with \\..
  • Keep / as-is for package and file paths.

Examples:

  • Exact interface type:
    • ^github\\.com/redis/go-redis/v9\\.UniversalClient$
  • Any interface in a package:
    • ^github\\.com/redis/go-redis/v9\\..+$
  • Broad suffix (use carefully):
    • .*\\.UniversalClient$

Common pitfall:

  • .*redis\\.UniversalClient does not match github.com/redis/go-redis/v9.UniversalClient because the real full name contains /go-redis/v9. before UniversalClient.

ownership

  • enabled (bool, default: true): toggles the ownership rule (IFG001). When false, no ownership diagnostics are reported.
  • contractscope (enum: exportedoutput|anyexported, default: exportedoutput): defines which interfaces are treated as contractual for the ownership rule.
    • exportedoutput: an interface is contractual only if it appears in the package’s exported API outputs (exported function/method results or exported variables). By default, interfaces used only in exported inputs are ignored unless skipifusedasinput=false.
    • anyexported: every exported interface is contractual, regardless of where it is used.
  • skipifusedasinput (bool, default: true): only applies to contractscope=exportedoutput. If true, interfaces that appear only in exported inputs (parameters) are not considered contractual. Rationale: input-only interfaces typically describe dependencies the package consumes, so this avoids ownership violations for consumer-side contracts. Set to false to treat exported inputs as part of the public contract (stricter DIP). Note: if the interface appears in exported outputs (including nested inside returned/variable types), it is not input-only and this flag does not suppress diagnostics.
  • contractpackages (list[regex], default: empty): regexes matched against interface package import paths. Matching packages are treated as allowed contract packages, so ownership violations are not reported there.
  • ignoreinterfaces (list[regex], default: empty): regexes matched against full interface names (pkgpath.Interface) to exclude them from the ownership rule.
  • ignoremarkerinterfaces (bool, default: true): if true, marker interfaces (no methods and no embedded interfaces, and not a type-set constraint) are excluded from the ownership rule. Marker interfaces are named empty interfaces used for readability; any type is assignable to them, so they do not enforce behavior. If you need enforcement, add a method or a type-set constraint instead.

assertions

  • enabled (bool, default: true): toggles assertion checks (IFG002), missing-assertion checks (IFG003), and bypass checks (IFG004). When false, no assertion diagnostics are reported.
  • wiringpackages (list[regex], default: empty): regexes matched against package import paths where assertions are allowed outside the implementation package (e.g., wiring/compose packages).
  • acceptconversiononlyform (bool, default: false): if true, also treats var _ = iface((*T)(nil)) as a valid assertion form (conversion-only form).
  • checkbypassassertions (bool, default: true): if true, enables IFG004 bypass detection (interface{...} or private assertion-only interface). Effective only when requireassertions=true.
  • scanfunctionbodies (bool, default: false): if true, scans inside function bodies for var _ I = ... assertions. When false, only package-level var declarations are scanned.
  • requireassertions (bool, default: false): if true, requires at least one recognized assertion in the implementation package for each type that implements a contractual interface from another package; reports IFG003 when missing. In this mode, ifaceguard can also report IFG004 when checkbypassassertions=true; bypass assertions are not counted for IFG003. When enabled, ifaceguard scans all packages in the current module to find contractual interfaces, so missing assertions are reported even if the implementation package does not reference the interface directly.
  • requireassertionsstrict (bool, default: false): if true, disables the relevance filter for IFG003 and reports missing assertions for any matching contractual interface in the module, even when no direct import/co-import evidence exists.

constructors

  • enabled (bool, default: true): toggles constructor return checks (IFG005). When false, no constructor-return diagnostics are reported.
  • namepatterns (list[regex], default: ["^New[A-Z]", "^MustNew[A-Z]"]): regexes matched against package-level function names to classify constructor-like functions.
  • exportedonly (bool, default: true): when true, only exported constructor-like functions are checked.
  • ignoreinterfaces (list[regex], default: empty): regexes matched against full interface names (pkgpath.Interface) to exclude from IFG005 checks.
  • ignoreerrorreturn (bool, default: true): when true, builtin error return values are ignored by IFG005.

exclude

  • files (list[regex], default: empty): regexes matched against full file paths (with / separators). Matching files are skipped entirely.
  • types (list[regex], default: empty): regexes matched against full type names (pkgpath.TypeName). Matching types are ignored by both rules.

Notes:

  • Test files are analyzed by default; use exclude.files to omit them if needed.
  • Unknown configuration keys are rejected with an error.
  • Assertion placement checks scan any var declarations (including grouped var (...)); wiringpackages depends only on the package path, not on the declaration form.

Examples: how settings affect diagnostics

The examples below use these diagnostic IDs:

  • IFG001-OWNERSHIP: interface ownership violation.
  • IFG002-ASSERTION-PLACEMENT: assertion placed outside the implementation package.
  • IFG003-ASSERTION-MISSING: missing assertion when requireassertions=true.
  • IFG004-ASSERTION-BYPASS: bypass assertion form under requireassertions=true.
  • IFG005-CONSTRUCTOR-INTERFACE-RETURN: constructor returns interface instead of concrete implementation type.

Ownership: contractscope and skipifusedasinput

Code:

// provider/provider.go
package provider

type Runner interface {
	Run() error
}

type Service struct{}

func (Service) Run() error { return nil }

// Exported API uses Runner only as input.
func Use(r Runner) {}

Config and expected diagnostics:

ownership:
  contractscope: exportedoutput
  skipifusedasinput: true
  • Expected: no IFG001-OWNERSHIP (input-only usage is ignored).
ownership:
  contractscope: exportedoutput
  skipifusedasinput: false
  • Expected: IFG001-OWNERSHIP on provider.Runner.
ownership:
  contractscope: anyexported
  • Expected: IFG001-OWNERSHIP on provider.Runner.

With exportedoutput, ifaceguard treats an interface as contractual only if it appears in exported results or exported variables. If it shows up only in exported inputs, it is ignored unless you set skipifusedasinput=false. With anyexported, any exported interface is contractual regardless of where it is used. This matches the idea that the public contract is primarily what the package returns/exports, while input-only dependencies are often consumer-side and should not force ownership unless you opt into stricter enforcement.

Ownership: contractpackages

Code:

// contract/runner.go
package contract

type Runner interface {
  Run() error
}

type Service struct{}

func (Service) Run() error { return nil }

// NewRunner returns Runner, making it contractual in this package.
func NewRunner() Runner {
  return Service{}
}

Config and expected diagnostics:

ownership:
  contractpackages:
    - "^example\\.com/project/contract(/|$)"
  • Expected: no IFG001-OWNERSHIP (contract package is allowed).

Without contractpackages, the same code would report IFG001-OWNERSHIP on contract.Runner because the interface and its implementation live in the same package and the interface appears in exported output.

If the interface’s package path matches contractpackages, ifaceguard treats it as a designated contract package and does not report ownership violations for interfaces declared there, even when implementations are in the same package. This lets you centralize contracts (and occasional helper implementations) without violating the ownership rule.

Ownership: ignoreinterfaces and ignoremarkerinterfaces

Code:

// provider/ignore.go
package provider

type Ignored interface {
	Run() error
}

type Service struct{}

func (Service) Run() error { return nil }

Config and expected diagnostics:

ownership:
  ignoreinterfaces:
    - "example\\.com/project/provider\\.Ignored$"
  • Expected: no IFG001-OWNERSHIP for provider.Ignored.

If ignoremarkerinterfaces=true, a marker interface (no methods and no embedded interfaces) is excluded from ownership checks in the same way.

Example of a marker interface case: if a package declares type Marker interface{} and exposes it via an exported API, IFG001 would be reported when ignoremarkerinterfaces=false. With ignoremarkerinterfaces=true, that marker interface is skipped.

Interfaces matching ignoreinterfaces are skipped entirely. When ignoremarkerinterfaces=true, marker interfaces (no methods/embeds and not type sets) are also skipped, so ownership diagnostics are not emitted for them. This is useful for explicit exceptions and to avoid noise from tag-like interfaces that do not enforce behavior.

Assertions: wiringpackages

Code:

// internal/compose/assertions.go
package compose

import (
	"example.com/project/contract"
	"example.com/project/provider"
)

var _ contract.Runner = (*provider.Service)(nil)

Config and expected diagnostics:

assertions:
  wiringpackages:
    - "^example\\.com/project/internal/compose(/|$)"
  • Expected: no IFG002-ASSERTION-PLACEMENT for this assertion.

Without the wiringpackages match, the same code reports IFG002-ASSERTION-PLACEMENT at the var _ ... line.

Assertions are expected to live in the implementation package; wiringpackages allows specific compose/wiring packages to host assertions without IFG002. This reflects architectures where wiring/assembly is centralized and assertions are placed alongside wiring code.

Assertions: requireassertions

Code:

// contract/runner.go
package contract

type Runner interface {
	Run() error
}
// provider/service.go
package provider

import "example.com/project/contract"

type Service struct{}

func (Service) Run() error { return nil }

// No compile-time assertion here.

Config and expected diagnostics:

assertions:
  requireassertions: true
  • Expected: IFG003-ASSERTION-MISSING on provider.Service.

If requireassertions=false, no IFG003-ASSERTION-MISSING is reported.

When requireassertions=true, ifaceguard reports IFG003 unless each implementation of a contractual external interface has at least one compile-time assertion in the implementation package. The goal is to make conformance explicit and prevent silent drift.

Assertions: requireassertionsstrict

Code:

// contract/runner.go
package contract

type Runner interface {
	Run() error
}
// provider/service.go
package provider

type Service struct{}

func (Service) Run() error { return nil }

// No import of contract and no compile-time assertion.

Config and expected diagnostics:

assertions:
  requireassertions: true
  requireassertionsstrict: true
  • Expected: IFG003-ASSERTION-MISSING on provider.Service, even when there is no import/co-import evidence between packages.

With requireassertionsstrict=false, ifaceguard keeps the relevance filter and may suppress IFG003 when there is no import/co-import evidence linking the implementation to the contractual interface.

Assertions: acceptconversiononlyform

Code:

// contract/assert.go
package contract

import "example.com/project/provider"

type Runner interface {
	Run() error
}

var _ = Runner((*provider.Service)(nil))

Config and expected diagnostics:

assertions:
  acceptconversiononlyform: true
  • Expected: IFG002-ASSERTION-PLACEMENT at the var _ = ... line.

If acceptconversiononlyform=false, this assertion form is ignored and no IFG002-ASSERTION-PLACEMENT is reported.

When acceptconversiononlyform=true, the conversion-only assertion form counts as an assertion and is checked for placement; when false, that form is ignored. This lets teams choose whether to accept the more implicit conversion-only style.

Assertions: scanfunctionbodies

Code:

// contract/assert.go
package contract

import "example.com/project/provider"

type Runner interface {
	Run() error
}

func init() {
	var _ Runner = (*provider.Service)(nil)
}

Config and expected diagnostics:

assertions:
  scanfunctionbodies: true
  • Expected: IFG002-ASSERTION-PLACEMENT for the var _ ... inside init.

If scanfunctionbodies=false, that in-function assertion is not scanned and no IFG002-ASSERTION-PLACEMENT is reported.

When scanfunctionbodies=true, ifaceguard scans inside function bodies for var _ I = ... assertions; when false, only package-level var declarations are scanned. This keeps the default focused on visible, package-level assertions while allowing deeper scans when needed.

Exclusions: exclude.files and exclude.types

Code:

// provider/generated.go
package provider

type Runner interface {
	Run() error
}

type Service struct{}

func (Service) Run() error { return nil }

Config and expected diagnostics:

exclude:
  files:
    - ".*_generated\\.go$"
  types:
    - "example\\.com/project/provider\\.Service$"
  • Expected: no diagnostics from provider/generated.go (file is excluded).
  • Expected: provider.Service is ignored by both rules due to exclude.types.

exclude.files skips entire files, and exclude.types skips matching types. Both rules ignore anything matched by these exclusions. This is useful for generated code, mocks, or legacy areas you intentionally exclude.

Requirements

Development Commands

  • task lint - Run linter
  • task test - Run all tests
  • task build - Build custom golangci-lint with ifaceguard linter and ifaceguard binary
  • task fmt - Format Go code
  • task check - Run lint and test (full validation)
  • task test:custom - Test custom bin/custom-gcl linter on this codebase. This is a "negative" test that should return errors, as the test data intentionally violates the rules.

About

A Go linter that checks architectural properties of interface usage

Topics

Resources

Stars

1 star

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages