diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..ea9c43b --- /dev/null +++ b/examples/README.md @@ -0,0 +1,7 @@ +## `Solid::Adapters` Examples + +> **Attention:** Each example has its own **README** with more details. + +1. [Ports and Adapters](./ports_and_adapters) - Implements the Ports and Adapters pattern. It uses **`Solid::Adapters::Interface`** to provide an interface from the application's core to other layers. + +2. [Anti-Corruption Layer](./anti_corruption_layer) - Implements the Anti-Corruption Layer pattern. It uses the **`Solid::Adapters::Proxy`** to define an interface for a set of adapters, which will translate an external interface (`vendors`) to the application's core interface. diff --git a/examples/anti_corruption_layer/README.md b/examples/anti_corruption_layer/README.md new file mode 100644 index 0000000..50a9d1c --- /dev/null +++ b/examples/anti_corruption_layer/README.md @@ -0,0 +1,212 @@ +- [🛡️ Anti-Corruption Layer Example](#️-anti-corruption-layer-example) +- [The ACL](#the-acl) + - [🤔 How does it work?](#-how-does-it-work) + - [📜 The Contract](#-the-contract) + - [🔄 The Adapters](#-the-adapters) +- [⚖️ What is the benefit of doing this?](#️-what-is-the-benefit-of-doing-this) + - [How much to do this (create ACL)?](#how-much-to-do-this-create-acl) + - [Is it worth the overhead of contract checking at runtime?](#is-it-worth-the-overhead-of-contract-checking-at-runtime) +- [🏃‍♂️ How to run the application?](#️-how-to-run-the-application) + +## 🛡️ Anti-Corruption Layer Example + +The **Anti-Corruption Layer**, or ACL, is a pattern that isolates and protects a system from legacy or dependencies out of its control. It acts as a mediator, translating and adapting data between different components, ensuring they communicate without corrupting each other's data or logic. + +To illustrate this pattern, let's see an example of an application that uses third-party API to charge a credit card. + +Let's start seeing the code structure of this example: + +``` +├── Rakefile +├── config.rb +├── app +│ └── models +│ └── payment +│ └── charge_credit_card.rb +├── lib +│ ├── payment_gateways +│ │ ├── adapters +│ │ │ ├── circle_up.rb +│ │ │ └── pay_friend.rb +│ │ ├── contract.rb +│ │ └── response.rb +│ └── payment_gateways.rb +└── vendor + ├── circle_up + │ └── client.rb + └── pay_friend + └── client.rb +``` + +The files and directories are organized as follows: + +- `Rakefile` runs the application. +- `config.rb` file contains the configurations. +- `app` directory contains the domain model where the business process to charge a credit card is implemented. +- `lib` directory contains the payment gateways contract and adapters. +- `vendor` directory contains the third-party API clients. + +## The ACL + +The ACL is implemented in the `PaymentGateways` module (see `lib/payment_gateways.rb`). It translates the third-party APIs (see `vendor`) into something known by the application's domain model. Through this module, the application can charge a credit card without knowing the details/internals of the vendors. + +### 🤔 How does it work? + +The `PaymentGateways::ChargeCreditCard` class (see `app/models/payment/charge_credit_card.rb`) uses`PaymentGateways::Contract` to ensure the `payment_gateway` object implements the required and known interface (input and output) to charge a credit card. + +```ruby +module Payment + class ChargeCreditCard + include ::Solid::Output.mixin(config: { addon: { continue: true } }) + + attr_reader :payment_gateway + + def initialize(payment_gateway) + @payment_gateway = ::PaymentGateways::Contract.new(payment_gateway) + end + + def call(amount:, details: {}) + Given(amount:) + .and_then(:validate_amount) + .and_then(:charge_credit_card, details:) + .and_expose(:payment_charged, %i[payment_id]) + end + + private + + def validate_amount(amount:) + return Continue() if amount.is_a?(::Numeric) && amount.positive? + + Failure(:invalid_amount, erros: ['amount must be positive']) + end + + def charge_credit_card(amount:, details:) + response = payment_gateway.charge_credit_card(amount:, details:) + + Continue(payment_id: ::SecureRandom.uuid) if response.success? + end + end +end +``` + +#### 📜 The Contract + +The `PaymentGateways::Contract` defines the interface of the payment gateways. It is implemented by the `PaymentGateways::Adapters::CircleUp` and `PaymentGateways::Adapters::PayFriend` adapters. + +```ruby +module PaymentGateways + class Contract < ::Solid::Adapters::Proxy + def charge_credit_card(params) + params => { amount: Numeric, details: Hash } + + outcome = object.charge_credit_card(params) + + outcome => Response[true | false] + + outcome + end + end +end +``` + +In this case, the contract will ensure the input by using the `=>` pattern-matching operator, which will raise an exception if it does not match the expected types. After that, it calls the adapter's `charge_credit_card` method and ensures the output is a `PaymentGateways::Response` by using the `=>` operator again. + +The response (see `lib/payment_gateways/response.rb`) will ensure the ACL, as it is the object known/exposed to the application. + +```ruby +module PaymentGateways + Response = ::Struct.new(:success?) +end +``` + +#### 🔄 The Adapters + +Let's see the payment gateways adapters: + +`lib/payment_gateways/adapters/circle_up.rb` + +```ruby +module PaymentGateways + class Adapters::CircleUp + attr_reader :client + + def initialize + @client = ::CircleUp::Client.new + end + + def charge_credit_card(params) + params => { amount:, details: } + + response = client.charge_cc(amount, details) + + Response.new(response.ok?) + end + end +end +``` + +`lib/payment_gateways/adapters/pay_friend.rb` + +```ruby +module PaymentGateways + class Adapters::PayFriend + attr_reader :client + + def initialize + @client = ::PayFriend::Client.new + end + + def charge_credit_card(params) + params => { amount:, details: } + + response = client.charge(amount:, payment_data: details, payment_method: 'credit_card') + + Response.new(response.status == 'success') + end + end +end +``` + +You can see that each third-party API has its way of charging a credit card, so the adapters are responsible for translating the input/output from the third-party APIs to the output known by the application (the `PaymentGateways::Response`). + +## ⚖️ What is the benefit of doing this? + +The benefit of doing this is that the core business logic is decoupled from the legacy/external dependencies, which makes it easier to test and promote changes in the code. + +Using this example, if the third-party APIs change, we just need to implement a new adapter and make the business processes (`Payment::ChargeCreditCard`) use it. The business processes will not be affected as it is protected by the ACL. + +### How much to do this (create ACL)? + +Use this pattern when there is a real need to decouple the core business logic from external dependencies. + +You can start with a simple implementation (without ACL) and refactor it to use this pattern when the need arises. + +### Is it worth the overhead of contract checking at runtime? + +You can eliminate the overhead by disabling the `Solid::Adapters::Proxy` class, which is a proxy that forwards all the method calls to the object it wraps. + +When it is disabled, the `Solid::Adapters::Proxy.new` returns the given object so that the method calls are made directly to it. + +To disable it, set the configuration to false: + +```ruby +Solid::Adapters.configuration do |config| + config.proxy_enabled = false +end +``` + +## 🏃‍♂️ How to run the application? + +In the same directory as this `README`, run: + +```bash +rake + +# -- CircleUp -- +# +# #"2df767d0-af83-4657-b28d-6605044ffe2c"}> +# +# -- PayFriend -- +# +# #"dd2af4cc-8484-4f6a-bc35-f7a5e6917ecc"}> +``` diff --git a/examples/anti_corruption_layer/Rakefile b/examples/anti_corruption_layer/Rakefile new file mode 100644 index 0000000..7016ff6 --- /dev/null +++ b/examples/anti_corruption_layer/Rakefile @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +if RUBY_VERSION <= '3.1' + puts 'This example requires Ruby 3.1 or higher.' + exit! 1 +end + +require_relative 'config' + +task :default do + puts '=====================' + puts 'Anti Corruption Layer' + puts '=====================' + + puts + puts '-- CircleUp --' + puts + + circle_up_gateway = PaymentGateways::Adapters::CircleUp.new + + p Payment::ChargeCreditCard.new(circle_up_gateway).call(amount: 100) + + puts + puts '-- PayFriend --' + puts + + pay_friend_gateway = PaymentGateways::Adapters::PayFriend.new + + p Payment::ChargeCreditCard.new(pay_friend_gateway).call(amount: 200) +end diff --git a/examples/anti_corruption_layer/app/models/payment/charge_credit_card.rb b/examples/anti_corruption_layer/app/models/payment/charge_credit_card.rb new file mode 100644 index 0000000..8bf77cf --- /dev/null +++ b/examples/anti_corruption_layer/app/models/payment/charge_credit_card.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +require 'securerandom' + +module Payment + class ChargeCreditCard + include ::Solid::Output.mixin(config: { addon: { continue: true } }) + + attr_reader :payment_gateway + + def initialize(payment_gateway) + @payment_gateway = ::PaymentGateways::Contract.new(payment_gateway) + end + + def call(amount:, details: {}) + Given(amount:) + .and_then(:validate_amount) + .and_then(:charge_credit_card, details:) + .and_expose(:payment_charged, %i[payment_id]) + end + + private + + def validate_amount(amount:) + return Continue() if amount.is_a?(::Numeric) && amount.positive? + + Failure(:invalid_amount, erros: ['amount must be positive']) + end + + def charge_credit_card(amount:, details:) + response = payment_gateway.charge_credit_card(amount:, details:) + + Continue(payment_id: ::SecureRandom.uuid) if response.success? + end + end +end diff --git a/examples/anti_corruption_layer/config.rb b/examples/anti_corruption_layer/config.rb new file mode 100644 index 0000000..0352d97 --- /dev/null +++ b/examples/anti_corruption_layer/config.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require "bundler/inline" + +$LOAD_PATH.unshift(__dir__) + +gemfile do + source "https://rubygems.org" + + gem "solid-result", "~> 2.0" + gem "solid-adapters", path: "../../" +end + +require "vendor/pay_friend/client" +require "vendor/circle_up/client" + +require "lib/payment_gateways" + +require "app/models/payment/charge_credit_card" diff --git a/examples/anti_corruption_layer/lib/payment_gateways.rb b/examples/anti_corruption_layer/lib/payment_gateways.rb new file mode 100644 index 0000000..7a9a9f8 --- /dev/null +++ b/examples/anti_corruption_layer/lib/payment_gateways.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module PaymentGateways + require_relative 'payment_gateways/contract' + require_relative 'payment_gateways/response' + + module Adapters + require_relative 'payment_gateways/adapters/circle_up' + require_relative 'payment_gateways/adapters/pay_friend' + end +end diff --git a/examples/anti_corruption_layer/lib/payment_gateways/adapters/circle_up.rb b/examples/anti_corruption_layer/lib/payment_gateways/adapters/circle_up.rb new file mode 100644 index 0000000..8f0f2a5 --- /dev/null +++ b/examples/anti_corruption_layer/lib/payment_gateways/adapters/circle_up.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module PaymentGateways + class Adapters::CircleUp + attr_reader :client + + def initialize + @client = ::CircleUp::Client.new + end + + def charge_credit_card(params) + params => { amount:, details: } + + response = client.charge_cc(amount, details) + + Response.new(response.ok?) + end + end +end diff --git a/examples/anti_corruption_layer/lib/payment_gateways/adapters/pay_friend.rb b/examples/anti_corruption_layer/lib/payment_gateways/adapters/pay_friend.rb new file mode 100644 index 0000000..b3d7ce3 --- /dev/null +++ b/examples/anti_corruption_layer/lib/payment_gateways/adapters/pay_friend.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module PaymentGateways + class Adapters::PayFriend + attr_reader :client + + def initialize + @client = ::PayFriend::Client.new + end + + def charge_credit_card(params) + params => { amount:, details: } + + response = client.charge(amount:, payment_data: details, payment_method: 'credit_card') + + Response.new(response.status == 'success') + end + end +end diff --git a/examples/anti_corruption_layer/lib/payment_gateways/contract.rb b/examples/anti_corruption_layer/lib/payment_gateways/contract.rb new file mode 100644 index 0000000..d020a2f --- /dev/null +++ b/examples/anti_corruption_layer/lib/payment_gateways/contract.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module PaymentGateways + class Contract < ::Solid::Adapters::Proxy + def charge_credit_card(params) + params => { amount: Numeric, details: Hash } + + outcome = object.charge_credit_card(params) + + outcome => Response[true | false] + + outcome + end + end +end diff --git a/examples/anti_corruption_layer/lib/payment_gateways/response.rb b/examples/anti_corruption_layer/lib/payment_gateways/response.rb new file mode 100644 index 0000000..d216abe --- /dev/null +++ b/examples/anti_corruption_layer/lib/payment_gateways/response.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +module PaymentGateways + Response = ::Struct.new(:success?) +end diff --git a/examples/anti_corruption_layer/vendor/circle_up/client.rb b/examples/anti_corruption_layer/vendor/circle_up/client.rb new file mode 100644 index 0000000..014a76e --- /dev/null +++ b/examples/anti_corruption_layer/vendor/circle_up/client.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module CircleUp + class Client + Resp = ::Struct.new(:ok?) + + def charge_cc(_amount, _credit_card_data) + Resp.new(true) + end + end +end diff --git a/examples/anti_corruption_layer/vendor/pay_friend/client.rb b/examples/anti_corruption_layer/vendor/pay_friend/client.rb new file mode 100644 index 0000000..8bb6494 --- /dev/null +++ b/examples/anti_corruption_layer/vendor/pay_friend/client.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module PayFriend + class Client + APIResponse = ::Struct.new(:status) + + def charge(amount:, payment_method:, payment_data:) + APIResponse.new('success') + end + end +end diff --git a/examples/ports_and_adapters/README.md b/examples/ports_and_adapters/README.md new file mode 100644 index 0000000..01d1bd0 --- /dev/null +++ b/examples/ports_and_adapters/README.md @@ -0,0 +1,154 @@ +- [🧩 Ports and Adapters Example](#-ports-and-adapters-example) + - [The Port](#the-port) + - [The Adapters](#the-adapters) +- [⚖️ What is the benefit of doing this?](#️-what-is-the-benefit-of-doing-this) + - [How much to do this (create Ports and Adapters)?](#how-much-to-do-this-create-ports-and-adapters) + - [Is it worth the overhead of contract checking at runtime?](#is-it-worth-the-overhead-of-contract-checking-at-runtime) +- [🏃‍♂️ How to run the application?](#️-how-to-run-the-application) + +## 🧩 Ports and Adapters Example + +Ports and Adapters is an architectural pattern that separates the application's core logic (Ports) from external dependencies (Adapters). + +This example shows how to implement a simple application using this pattern and the gem `solid-adapters`. + +Let's start seeing the code structure: + +``` +├── Rakefile +├── config.rb +├── db +├── app +│ └── models +│ └── user +│ ├── record +│ │ └── repository.rb +│ └── record.rb +├── lib +│ └── user +│ ├── creation.rb +│ ├── data.rb +│ └── repository.rb +└── test + └── user_test + └── repository.rb +``` + +The files and directories are organized as follows: + +- `Rakefile` runs the application. +- `config.rb` file contains the configuration of the application. +- `db` directory contains the database. It is not part of the application, but it is used by the application. +- `app` directory contains "Rails" components. +- `lib` directory contains the core business logic. +- `test` directory contains the tests. + +The application is a simple "user management system". It unique core functionality is to create users. + +Now we understand the code structure, let's see the how the pattern is implemented. + +### The Port + +In this application, there is only one business process: `User::Creation` (see `lib/user/creation.rb`), which relies on the `User::Repository` (see `lib/user/repository.rb`) to persist the user. + +The `User::Repository` is an example of **port**, because it is an interface/contract that defines how the core business logic will persist user records. + +```ruby +module User::Repository + include Solid::Adapters::Interface + + module Methods + def create(name:, email:) + name => String + email => String + + super.tap { _1 => ::User::Data[id: Integer, name: String, email: String] } + end + end +end +``` + +### The Adapters + +The `User::Repository` is implemented by two adapters: + +- `User::Record::Repository` (see `app/models/user/record/repository.rb`) is an adapter that persists user records in the database (through the `User::Record`, that is an `ActiveRecord` model). + +- `UserTest::Repository` (see `test/user_test/repository.rb`) is an adapter that persists user records in memory (through the `UserTest::Data`, that is a simple in-memory data structure). + +## ⚖️ What is the benefit of doing this? + +The benefit of doing this is that the core business logic is decoupled from the external dependencies, which makes it easier to test and promote changes in the code. + +For example, if we need to change the persistence layer (start to send the data to a REST API or a Redis DB), we just need to implement a new adapter and make the business processes (`User::Creation`) use it. + +### How much to do this (create Ports and Adapters)? + +Use this pattern when there is a real need to decouple the core business logic from external dependencies. + +You can start with a simple implementation (without Ports and Adapters) and refactor it to use this pattern when the need arises. + +### Is it worth the overhead of contract checking at runtime? + +You can eliminate the overhead by disabling the `Solid::Adapters::Interface`, which is enabled by default. + +When it is disabled, the `Solid::Adapters::Interface` won't prepend the interface methods module to the adapter, which means that the adapter won't be checked against the interface. + +To disable it, set the configuration to false: + +```ruby +Solid::Adapters.configuration do |config| + config.interface_enabled = false +end +``` + +## 🏃‍♂️ How to run the application? + +In the same directory as this `README`, run: + +```bash +rake # or rake SOLID_ADAPTERS_ENABLED=enabled + +# or + +rake SOLID_ADAPTERS_ENABLED=false +``` + +**Proxy enabled** + +```bash +rake # or rake SOLID_ADAPTERS_ENABLED=enabled + +# Output sample: +# +# -- Valid input -- +# +# Created user: # +# Created user: # +# +# -- Invalid input -- +# +# rake aborted! +# NoMatchingPatternError: nil: String === nil does not return true (NoMatchingPatternError) +# /.../lib/user/repository.rb:9:in `create' +# /.../lib/user/creation.rb:12:in `call' +# /.../Rakefile:36:in `block in ' +``` + +**Proxy disabled** + +```bash +rake SOLID_ADAPTERS_ENABLED=false + +# Output sample: +# +# -- Valid input -- +# +# Created user: # +# Created user: # +# +# -- Invalid input -- +# +# Created user: # +# Created user: # +``` diff --git a/examples/ports_and_adapters/Rakefile b/examples/ports_and_adapters/Rakefile new file mode 100644 index 0000000..9d1c71b --- /dev/null +++ b/examples/ports_and_adapters/Rakefile @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +if RUBY_VERSION <= "3.1" + puts "This example requires Ruby 3.1 or higher." + exit! 1 +end + +require_relative "config" + +require_relative "test/user_test/repository" + +task :default do + puts + puts "------------------" + puts "Ports and Adapters" + puts "------------------" + + # -- User creation instances + + db_creation = User::Creation.new(repository: User::Record::Repository) + + memory_creation = User::Creation.new(repository: UserTest::Repository.new) + + puts + puts "-- Valid input --" + puts + + db_creation.call(name: "Jane", email: "jane@foo.com") + + memory_creation.call(name: "John", email: "john@bar.com") + + puts + puts "-- Invalid input --" + puts + + db_creation.call(name: "Jane", email: nil) + + memory_creation.call(name: "", email: nil) +end + +# Output sample: rake SOLID_ADAPTERS_ENABLED=true +# +# -- Valid input -- +# +# Created user: # +# Created user: # +# +# -- Invalid input -- +# +# rake aborted! +# NoMatchingPatternError: nil: String === nil does not return true (NoMatchingPatternError) +# /.../lib/user/repository.rb:9:in `create' +# /.../lib/user/creation.rb:12:in `call' +# /.../Rakefile:36:in `block in ' + +# Output sample: rake SOLID_ADAPTERS_ENABLED=false +# +# -- Valid input -- +# +# Created user: # +# Created user: # +# +# -- Invalid input -- +# +# Created user: # +# Created user: # diff --git a/examples/ports_and_adapters/app/models/user/record.rb b/examples/ports_and_adapters/app/models/user/record.rb new file mode 100644 index 0000000..079ffe4 --- /dev/null +++ b/examples/ports_and_adapters/app/models/user/record.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module User + class Record < ActiveRecord::Base + self.table_name = 'users' + end +end diff --git a/examples/ports_and_adapters/app/models/user/record/repository.rb b/examples/ports_and_adapters/app/models/user/record/repository.rb new file mode 100644 index 0000000..6331798 --- /dev/null +++ b/examples/ports_and_adapters/app/models/user/record/repository.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module User + module Record::Repository + extend ::User::Repository + + def self.create(name:, email:) + record = Record.create!(name:, email:) + + ::User::Data.new(id: record.id, name: record.name, email: record.email) + end + end +end diff --git a/examples/ports_and_adapters/config.rb b/examples/ports_and_adapters/config.rb new file mode 100644 index 0000000..094f2ce --- /dev/null +++ b/examples/ports_and_adapters/config.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +require "bundler/inline" + +$LOAD_PATH.unshift(__dir__) + +gemfile do + source "https://rubygems.org" + + gem "sqlite3", "~> 1.7" + gem "activerecord", "~> 7.1", ">= 7.1.2", require: "active_record" + gem "solid-adapters", path: "../../" +end + +require "active_support/all" + +require "db/setup" + +::Solid::Adapters.configuration do |config| + enabled = ENV.fetch("SOLID_ADAPTERS_ENABLED", "true") != "false" + + config.interface_enabled = enabled +end + +module User + require "lib/user/data" + require "lib/user/repository" + require "lib/user/creation" +end + +require "app/models/user/record" +require "app/models/user/record/repository" diff --git a/examples/ports_and_adapters/db/setup.rb b/examples/ports_and_adapters/db/setup.rb new file mode 100644 index 0000000..70fcce0 --- /dev/null +++ b/examples/ports_and_adapters/db/setup.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +require 'active_support/all' + +ActiveRecord::Base.establish_connection( + host: 'localhost', + adapter: 'sqlite3', + database: ':memory:' +) + +ActiveRecord::Schema.define do + create_table :users do |t| + t.column :name, :string + t.column :email, :string + end +end diff --git a/examples/ports_and_adapters/lib/user/creation.rb b/examples/ports_and_adapters/lib/user/creation.rb new file mode 100644 index 0000000..387b100 --- /dev/null +++ b/examples/ports_and_adapters/lib/user/creation.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module User + class Creation + def initialize(repository:) + repository => Repository + + @repository = repository + end + + def call(name:, email:) + user_data = @repository.create(name:, email:) + + puts "Created user: #{user_data.inspect}" + + user_data + end + end +end diff --git a/examples/ports_and_adapters/lib/user/data.rb b/examples/ports_and_adapters/lib/user/data.rb new file mode 100644 index 0000000..2b970e0 --- /dev/null +++ b/examples/ports_and_adapters/lib/user/data.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +module User + Data = ::Struct.new(:id, :name, :email, keyword_init: true) +end diff --git a/examples/ports_and_adapters/lib/user/repository.rb b/examples/ports_and_adapters/lib/user/repository.rb new file mode 100644 index 0000000..2c3028c --- /dev/null +++ b/examples/ports_and_adapters/lib/user/repository.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module User::Repository + include Solid::Adapters::Interface + + module Methods + def create(name:, email:) + name => String + email => String + + super.tap { _1 => ::User::Data[id: Integer, name: String, email: String] } + end + end +end diff --git a/examples/ports_and_adapters/test/user_test/repository.rb b/examples/ports_and_adapters/test/user_test/repository.rb new file mode 100644 index 0000000..4cfcad4 --- /dev/null +++ b/examples/ports_and_adapters/test/user_test/repository.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module UserTest + class Repository + include ::User::Repository + + attr_reader :records + + def initialize + @records = [] + end + + def create(name:, email:) + id = @records.size + 1 + + @records[id] = { id:, name:, email: } + + ::User::Data.new(id:, name:, email:) + end + end +end