Module: ArchSpec::Architectures

Extended by:
Architectures
Included in:
Architectures
Defined in:
lib/archspec/architectures.rb

Overview

Bundled architecture presets. Each applies a set of components and rules in one call, invoked from the DSL through ArchSpec::DSL::Context#architecture:

architecture :rails
architecture :layered, layers: { ... }

Every preset accepts overrides for its directories, so you can keep the shape while pointing at your own paths. The presets are:

  • :rails: conventional MVC that keeps controller APIs out of models and services. Options components:, controller_api:, share_helpers:.
  • :rails_strict: :rails plus a cycle check and a concern independence check. Adds option concerns:.
  • :vanilla_rails: :rails plus empty-directory rules for the 37signals style (forbidding app/services, app/forms, app/policies, and more) and the concern independence check. Options components:, empty:, controller_api:, share_helpers:, concerns:.
  • :layered: ordered layers that may only depend inward, with a cycle check. Option layers: (order matters).
  • :hexagonal: ports and adapters, keeping the domain away from adapters. Options application:, domain:, ports:, adapters:.
  • :clean: clean architecture layers. Options frameworks:, interface_adapters:, use_cases:, entities:.
  • :modular_monolith: named packages with per-package allowlists and optional public APIs. Options components: (required), allow:, public:.
  • :cqrs: separates commands from queries and keeps writes out of queries. Options commands:, queries:, read_models:, mutating_methods:.
  • :event_driven: events, publishers, and subscribers. Options events:, publishers:, subscribers:.
  • :ruby_conventions: generic Ruby naming idioms (no +get_+/+set_+, no is_ prefix), applied project-wide. Adds no components, so it composes with any other architecture. No options.

See the guides at https://archspecrb.dev/architectures/ for each in depth.

Constant Summary collapse

DEFAULT_LAYERED =
{
  interface: 'app/controllers/**/*.rb',
  application: %w[app/services/**/*.rb app/jobs/**/*.rb app/mailers/**/*.rb],
  domain: 'app/models/**/*.rb'
}.freeze
DEFAULT_RAILS_MVC =
{
  controllers: 'app/controllers/**/*.rb',
  models: 'app/models/**/*.rb',
  helpers: 'app/helpers/**/*.rb',
  mailers: 'app/mailers/**/*.rb',
  jobs: 'app/jobs/**/*.rb',
  services: 'app/services/**/*.rb'
}.freeze
DEFAULT_HEXAGONAL =
{
  application: %w[app/services/**/*.rb app/use_cases/**/*.rb],
  domain: 'app/domain/**/*.rb',
  ports: 'app/ports/**/*.rb',
  adapters: %w[app/adapters/**/*.rb app/integrations/**/*.rb app/infrastructure/**/*.rb]
}.freeze
DEFAULT_CLEAN =
{
  frameworks: %w[app/controllers/**/*.rb app/jobs/**/*.rb app/mailers/**/*.rb],
  interface_adapters: %w[app/adapters/**/*.rb app/presenters/**/*.rb app/serializers/**/*.rb],
  use_cases: %w[app/use_cases/**/*.rb app/services/**/*.rb],
  entities: %w[app/entities/**/*.rb app/domain/**/*.rb app/models/**/*.rb]
}.freeze
DEFAULT_CQRS =
{
  commands: 'app/commands/**/*.rb',
  queries: 'app/queries/**/*.rb',
  read_models: 'app/read_models/**/*.rb'
}.freeze
DEFAULT_EVENT_DRIVEN =
{
  events: 'app/events/**/*.rb',
  publishers: 'app/publishers/**/*.rb',
  subscribers: 'app/subscribers/**/*.rb'
}.freeze
VANILLA_RAILS_EMPTY =
{
  services: ['app/services/**/*.rb', 'behavior belongs on models, not service objects'],
  forms: ['app/forms/**/*.rb', 'use strong parameters and model validations'],
  policies: ['app/policies/**/*.rb', 'authorization is predicate methods on models'],
  decorators: ['app/decorators/**/*.rb', 'use helpers and ERB partials'],
  presenters: ['app/presenters/**/*.rb', 'presentation objects are POROs in app/models'],
  view_components: ['app/components/**/*.rb', 'use helpers and ERB partials']
}.freeze
DEFAULT_CONCERNS =
'app/**/concerns/**/*.rb'
CONTROLLER_METHODS =
%i[render redirect_to params session cookies flash].freeze
MUTATING_METHODS =
%i[
  create create!
  delete delete_all
  destroy destroy!
  insert insert!
  save save!
  update update! update_attribute update_attributes update_columns
  upsert upsert!
].freeze
DEFAULTS =

Every option each architecture accepts, with its default. The single source of truth for #apply: option validation checks these keys, and the architecture methods receive these values merged with the caller's.

{
  rails: {
    components: DEFAULT_RAILS_MVC,
    controller_api: CONTROLLER_METHODS,
    share_helpers: false
  },
  rails_strict: {
    components: DEFAULT_RAILS_MVC,
    controller_api: CONTROLLER_METHODS,
    share_helpers: false,
    concerns: DEFAULT_CONCERNS
  },
  vanilla_rails: {
    components: DEFAULT_RAILS_MVC,
    empty: VANILLA_RAILS_EMPTY,
    controller_api: CONTROLLER_METHODS,
    share_helpers: false,
    concerns: DEFAULT_CONCERNS
  },
  layered: { layers: DEFAULT_LAYERED },
  hexagonal: DEFAULT_HEXAGONAL,
  clean: DEFAULT_CLEAN,
  modular_monolith: { components: nil, allow: {}, public: {} },
  cqrs: DEFAULT_CQRS.merge(mutating_methods: MUTATING_METHODS),
  event_driven: DEFAULT_EVENT_DRIVEN,
  ruby_conventions: {}
}.freeze

Instance Method Summary collapse

Instance Method Details

#apply(name, dsl, **options) ⇒ Object

Applies the named preset to dsl, forwarding options to it. Raises ArchSpec::Error for an unknown name. Called by ArchSpec::DSL::Context#architecture, so you rarely call it directly.

Raises:



140
141
142
143
144
145
146
147
# File 'lib/archspec/architectures.rb', line 140

def apply(name, dsl, **options)
  name = architecture_name(name)
  defaults = DEFAULTS[name]
  raise Error, "unknown architecture: #{name.inspect}" unless defaults

  validate_options!(name, defaults, options)
  send(name, dsl, **defaults.merge(options))
end

#clean(dsl, frameworks:, interface_adapters:, use_cases:, entities:) ⇒ Object



214
215
216
217
218
219
220
221
222
223
224
# File 'lib/archspec/architectures.rb', line 214

def clean(dsl, frameworks:, interface_adapters:, use_cases:, entities:)
  layered(
    dsl,
    layers: {
      frameworks: frameworks,
      interface_adapters: interface_adapters,
      use_cases: use_cases,
      entities: entities
    }
  )
end

#cqrs(dsl, commands:, queries:, read_models:, mutating_methods:) ⇒ Object



243
244
245
246
247
248
249
250
251
252
# File 'lib/archspec/architectures.rb', line 243

def cqrs(dsl, commands:, queries:, read_models:, mutating_methods:)
  components = normalize_map(commands: commands, queries: queries)
  components[:read_models] = read_models if read_models
  define_components(dsl, components)

  proxy_for(dsl, :commands).cannot_use :queries
  proxy_for(dsl, :queries).cannot_use :commands
  proxy_for(dsl, :queries).cannot_call(*mutating_methods)
  dsl.no_cycles(among: components.keys)
end

#event_driven(dsl, events:, publishers:, subscribers:) ⇒ Object



254
255
256
257
258
259
260
261
262
# File 'lib/archspec/architectures.rb', line 254

def event_driven(dsl, events:, publishers:, subscribers:)
  roles = normalize_map(events: events, publishers: publishers, subscribers: subscribers)
  define_components(dsl, roles)

  proxy_for(dsl, :events).cannot_use :publishers, :subscribers
  proxy_for(dsl, :publishers).can_only_use :events
  proxy_for(dsl, :subscribers).can_only_use :events
  dsl.no_cycles(among: roles.keys)
end

#hexagonal(dsl, application:, domain:, ports:, adapters:) ⇒ Object



198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
# File 'lib/archspec/architectures.rb', line 198

def hexagonal(dsl, application:, domain:, ports:, adapters:)
  roles = normalize_map(
    application: application,
    domain: domain,
    ports: ports,
    adapters: adapters
  )
  define_components(dsl, roles)

  proxy_for(dsl, :application).can_only_use :domain, :ports
  proxy_for(dsl, :domain).cannot_use :adapters
  proxy_for(dsl, :ports).cannot_use :adapters
  proxy_for(dsl, :adapters).can_only_use :application, :domain, :ports
  dsl.no_cycles(among: roles.keys)
end

#layered(dsl, layers:) ⇒ Object



185
186
187
188
189
190
191
192
193
194
195
196
# File 'lib/archspec/architectures.rb', line 185

def layered(dsl, layers:)
  ordered = normalize_map(layers)
  define_components(dsl, ordered)
  names = ordered.keys

  names.each_with_index do |name, index|
    allowed = names[(index + 1)..] || []
    proxy_for(dsl, name).can_only_use(*allowed)
  end

  dsl.no_cycles(among: names)
end

#modular_monolith(dsl, components:, allow: {}, public: {}) ⇒ Object

Raises:



226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
# File 'lib/archspec/architectures.rb', line 226

def modular_monolith(dsl, components:, allow: {}, public: {})
  raise Error, 'architecture :modular_monolith requires the components: option' unless components

  components = normalize_map(components)
  define_components(dsl, components)

  components.each_key do |name|
    allowed = Array(allow[name] || allow[name.to_s])
    proxy_for(dsl, name).can_only_use(*allowed)

    patterns = Array(public[name] || public[name.to_s])
    proxy_for(dsl, name).public_api(*patterns) if patterns.any?
  end

  dsl.no_cycles(among: components.keys)
end

#rails(dsl, components:, controller_api:, share_helpers:) ⇒ Object



149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
# File 'lib/archspec/architectures.rb', line 149

def rails(dsl, components:, controller_api:, share_helpers:)
  components = normalize_map(components)
  missing = %i[controllers models] - components.keys
  if missing.any?
    raise Error, "the rails architectures need controllers and models components, missing: #{missing.join(', ')}"
  end

  define_components(dsl, components)

  forbidden = (share_helpers ? %i[controllers] : %i[controllers helpers]) & components.keys
  proxy_for(dsl, :controllers).can_only_use(*components.keys & %i[models services helpers mailers jobs])

  (%i[models services] & components.keys).each do |name|
    proxy = proxy_for(dsl, name)
    proxy.cannot_use(*forbidden)
    proxy.cannot_call(*controller_api, receiver: :none) unless controller_api.empty?
  end
end

#rails_strict(dsl, components:, controller_api:, share_helpers:, concerns:) ⇒ Object



168
169
170
171
172
173
# File 'lib/archspec/architectures.rb', line 168

def rails_strict(dsl, components:, controller_api:, share_helpers:, concerns:)
  components = normalize_map(components)
  rails(dsl, components: components, controller_api: controller_api, share_helpers: share_helpers)
  dsl.no_cycles(among: components.keys)
  independent_concerns(dsl, concerns)
end

#ruby_conventions(dsl) ⇒ Object

Applies the generic Ruby naming idioms project-wide: no +get_+/+set_+ accessors and no is_ predicate prefix. Adds no components, so it composes with any other architecture. Project-specific conventions (the with_x / without_x pairing, the supports_*? ban) stay opt-in through the method_names.matching(...) primitives.



269
270
271
272
273
274
# File 'lib/archspec/architectures.rb', line 269

def ruby_conventions(dsl)
  %i[instance class].each do |scope|
    forbid_name(dsl, /\A(get|set)_/, 'use attr_ readers and writers or plain names, not get_/set_', scope: scope)
    forbid_name(dsl, /\Ais_/, 'name predicates with a trailing ? and no is_ prefix (has_ is fine)', scope: scope)
  end
end

#vanilla_rails(dsl, components:, empty:, controller_api:, share_helpers:, concerns:) ⇒ Object



175
176
177
178
179
180
181
182
183
# File 'lib/archspec/architectures.rb', line 175

def vanilla_rails(dsl, components:, empty:, controller_api:, share_helpers:, concerns:)
  rails(dsl, components: components, controller_api: controller_api, share_helpers: share_helpers)

  empty.each do |name, (pattern, reason)|
    dsl.component(name, in: pattern).must_be_empty(because: reason)
  end

  independent_concerns(dsl, concerns)
end