Writing Better Contracts

(with functional approach)

Dolganov Sergey @ Evil Martians

Title Text

eBay for Business

Distributed system with lots of API integrations and calls

Sounds like a plan

  • Design by Contracts
  • Existing Solution
  • Contract for API Client
  • Do we need contracts after all?

Thinking with Contracts

Contract is ...

  • Preconditions

  • Postconditions

  • Invariants

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

«If it ain't broken,

why fix it?»

Ruby Contracts Critique

(screen from Reddit e.g.)

Okay. Looks like we don't need contracts...

API Client Problem

Why we have to control an API call?

  • Sandbox behaves in a different way

  • No sandbox

  • Complex logic to verify response consistency

PRODUCTION DEBUG YOUR FUTURE IS

How to speed up debugging? 

How we handle it?

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

But actually...

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

But actually...

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
 

But actually...

Refined Types of Request and Response +

Assertion on Each Call +

Pattern Matching =

Contracts

Damn!

We reinvented contracts

SOMEONE IMPLEMENTED CONTRACTS WITH FINE
DSL BUT FAILED TO ADOPT...

WE'LL USE FUNCTIONAL APPROACH THIS TIME!

Example of Usage

  • PORO

  • tiny little syntactic sugar (if you want)

  • full strength of contracts in runtime

  • adequate errors processing

      
        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

Blood Contracts

Ruby Gem (ongoing Go, Rust, Haskell)

Blood Contracts Core

Ruby Gem (ongoing Go, Rust, Haskell)

Blood Contracts

Ruby Gem (ongoing Go, Rust, Haskell)

How to write a contract?

Find your types:

  1. figure out what kind of data Sets you use as a Request and Response 

  2. express rules to select them as Policy objects (or Refinement Types)

  3. use their composition as a contract definition

Do we need contracts after all?

They're fit best for a functional block that interacts with external systems

 

Contracts help us to manage growing entropy

Further Reading

  • Chaos Engineering

  • Algebraic Data Types

  • Maybe Haskell

Thanks

Twitter: @ss_dolganov

GitHub: @sclinede

 

Writing Better Contracts

By Sergey Dolganov

Writing Better Contracts

  • 472