A Go linter that checks architectural properties of interface usage.
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
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) whenrequireassertions=trueandcheckbypassassertions=true.
These checks can be enabled or disabled independently via ownership.enabled and assertions.enabled.
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.
go install github.com/n-r-w/ifaceguard/cmd/ifaceguard@latestbrew install n-r-w/homebrew-tap/ifaceguardYou can also tap first and install by formula name:
brew tap n-r-w/tap
brew install ifaceguardPrebuilt artifacts are published on the GitHub Releases page.
Release assets include:
- Standalone
ifaceguardbinary, namedifaceguard_<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.
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.*$"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 pathNote: 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: latestBuild the custom golangci-lint binary:
task buildThen 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.*$"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\\.UniversalClientdoes not matchgithub.com/redis/go-redis/v9.UniversalClientbecause the real full name contains/go-redis/v9.beforeUniversalClient.
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 unlessskipifusedasinput=false.anyexported: every exported interface is contractual, regardless of where it is used.
skipifusedasinput(bool, default: true): only applies tocontractscope=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.
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 treatsvar _ = 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 whenrequireassertions=true.scanfunctionbodies(bool, default: false): if true, scans inside function bodies forvar _ I = ...assertions. When false, only package-levelvardeclarations 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 whencheckbypassassertions=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.
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, builtinerrorreturn values are ignored by IFG005.
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.filesto omit them if needed. - Unknown configuration keys are rejected with an error.
- Assertion placement checks scan any
vardeclarations (including groupedvar (...));wiringpackagesdepends only on the package path, not on the declaration form.
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 whenrequireassertions=true.IFG004-ASSERTION-BYPASS: bypass assertion form underrequireassertions=true.IFG005-CONSTRUCTOR-INTERFACE-RETURN: constructor returns interface instead of concrete implementation type.
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-OWNERSHIPonprovider.Runner.
ownership:
contractscope: anyexported- Expected:
IFG001-OWNERSHIPonprovider.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.
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.
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-OWNERSHIPforprovider.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.
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-PLACEMENTfor 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.
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-MISSINGonprovider.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.
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-MISSINGonprovider.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.
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-PLACEMENTat thevar _ = ...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.
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-PLACEMENTfor thevar _ ...insideinit.
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.
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.Serviceis ignored by both rules due toexclude.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.
task lint- Run lintertask test- Run all teststask build- Build custom golangci-lint with ifaceguard linter and ifaceguard binarytask fmt- Format Go codetask check- Run lint and test (full validation)task test:custom- Test custombin/custom-gcllinter on this codebase. This is a "negative" test that should return errors, as the test data intentionally violates the rules.