Sergey Dolganov
Evil Martians developer, open-source enthusiast, traveller and drummer.
(with functional approach)
Dolganov Sergey @ Evil Martians
Distributed system with lots of API integrations and calls
Input
Output
function (input) { ... } # =>
State
Preconditions
Postconditions
Invariants
Contract
Contract 1 => 1
def fact x
x
end
Contract C::Num => C::Num
def fact x
x * fact(x - 1)
end
# instead of
def fact x
if x == 1
x
else
x * fact(x - 1)
end
end
Contract lambda { |n| n < 12 } => Ticket
def get_ticket(age)
ChildTicket.new(age: age)
end
Contract lambda { |n| n >= 12 } => Ticket
def get_ticket(age)
AdultTicket.new(age: age)
end
(screen from Reddit e.g.)
How to speed up debugging?
Ruby example with request/response mappers and policy objects
module RussianPost::Domestic::Calculator
attr_reader :errors
def initialize(parcel)
@parcel = parcel
end
def call
policy = InternalParcelPolicy[parcel]
return if policy.invalid?
policy = RuPostDomesticTariffPolicy[parcel]
return if policy.invalid?
response = RussianPost::API.get_tariff(parcel: parcel)
mapped_response = GetTariffResponseMapper[response]
policy = RuPostTariffResponsePolicy[response, mapped_response]
return if policy.invalid?
parcel.update_tariff!(russian_post: mapped_response)
ensure
@errors = policy.errors
end
end
if (service = RussianPost::Calculator.new(parcel)).call
render json: { tariff: parcel.reload.tariffs[:russian_post] }
else
render json: { errors: { base: service.errors } }
end
Container for value +
Validation Rules =
Refinement Types
class Tariff
def initialize(response)
@response, @errors = response, []
end
def match # or you may prefer #call
if @response.to_h.values_at(:cost, :currency, :delivery_date)
.compact.size != 3
@errors << :tariff_is_incomplete # key for i18n
end
self
end
def valid?
@errors.empty?
end
def unpack
raise "This is not what you're looking for" unless valid?
response.slice(:cost, :currency, :delivery_date)
end
end
if (tariff = Tariff.new(RussianPost::API.get_tariff(input)).match).valid?
render json: { tariff: tariff.unpack }
else
render json: { errors: tariff.errors.map(method(:translate)) }
end
Different Types of Responses +
Different Types of Behaviour =
Pattern Matching
response_types = [Tariff, NotSupportedRoute, RecoverableInputError]
case r = response_types.map! { |t| t.new(response).match }.find(&:valid?)
when(Tariff)
render json: { tariff: r.unpack }
when(NotSupportedRoute)
render json: { errors: { base: "Delivery is not available for the route" } }
when(RecoverableInputError)
# show errors to user
render json: { errors: { base: r.unpack } }
else
Honeybadger.notify("Unexpected Russian Post behaviour", context: response)
render json: { errors: { base: "Sorry, the API is not available for a while" } }
end
Refined Types of Request and Response +
Assertion on Each Call +
Pattern Matching =
Contracts
SOMEONE IMPLEMENTED CONTRACTS WITH FINE
DSL BUT FAILED TO ADOPT...
WE'LL USE FUNCTIONAL APPROACH THIS TIME!
class RussianPost::API
def get_tariff(input)
GetTariffContract.call(input) do |typed_input|
http_client.get('/1.0/tariff', typed_input.unpack)
end # returns a typed response
end
end
class GetTariffContract
def self.call(*input)
input_match = (DomesticParcel | InternationalParcel).match(*input)
return input_match if input_match.invalid?
result = yield(input_match)
(Tariff | NotSupportedRoute | RecoverableInputError).match(result)
end
end
case (match = RuPost::API.get_tariff(parcel))
when ContractFailure
Honeybadger.notify "Unexpected behavior in Russian Post",
context: match.context
render json: { errors: match.errors }
when Tariff
# работаем с тарифом
render json: { tariff: match.unpack }
when RecoverableInputError
# работаем с ошибкой, e.g. адрес слишком длинный
render json: { errors: match.unpack }
end
Ruby Gem (ongoing Go, Rust, Haskell)
Ruby Gem (ongoing Go, Rust, Haskell)
Ruby Gem (ongoing Go, Rust, Haskell)
They're fit best for a functional block that interacts with external systems
Contracts help us to manage growing entropy
By Sergey Dolganov
Evil Martians developer, open-source enthusiast, traveller and drummer.