Skip to content

Instantly share code, notes, and snippets.

@Velrok
Last active August 2, 2024 21:33
Show Gist options
  • Save Velrok/8e8338e0bf3f19c8c0ae01776054d96d to your computer and use it in GitHub Desktop.
Save Velrok/8e8338e0bf3f19c8c0ae01776054d96d to your computer and use it in GitHub Desktop.

The scenario

At a certain point in time we want to lock a CMI period to a forecasted amount.

possible business outcomes:

  1. A lock already exists
  2. We lock in the same CMI
  3. We lock in a different CMI

Other issues are possible:

  1. the period is in the past
  2. the forecasting encounters issues

Modeling Business outcomes as sorbet types

  1. A lock already exists
  2. We lock in the same CMI
  3. We lock in a different CMI

Modeling Business outcomes as sorbet types

Model Types

# T::Struct benefits:
# typed, immutable if used with const
# can hold data (amount in this case)
class ScheduledSame < T::Struct
  const :amount, Money
end

class ScheduledDifferent < T::Struct
  const :amount, Money
end

class AlreadyLocked < T::Struct; end

# Type alias instead of T::Enum so we can use Structs
Response = T.type_alias { T.any(ScheduledSame, ScheduledDifferent, AlreadyLocked) }

Modeling Business outcomes as sorbet types

Implementation

sig {params(period: Integer, amount: Money).returns(Response)}
def forecast_and_lock(period:, amount:)
  if already_locked?(period)
    AlreadyLocked.new
  else
    forecasted = forcast_cmi(period)
    if T.cast(forecasted == amount, T::Boolean)
      ScheduledSame.new(amount:)
    else
      ScheduledDifferent.new(amount: forecasted)
    end
  end
end

Modeling Business outcomes as sorbet types

Call site

sig { void }
def callsite
  lock_result = forecast_and_lock(period:10, amount:Money.new(200))

  case lock_result
  when ScheduledSame
    # happy path
  when ScheduledDifferent
    # inform customer
  when AlreadyLocked
    # maybe raise if we dont expect this at this point
  else
    # -- Without the last two `when` we get:
    # Control flow could reach `T.absurd` because the type
    # `T.any(ScheduledDifferent, AlreadyLocked)` wasn't handled
    T.absurd(lock_result)
  end
end

Modeling Business outcomes as sorbet types

Raising exceptions

My rule of thumb: raise for exceptions for invalid usage, inputs or when build in assumptions are not met. These are truly exceptional case we don't support.

sig {params(period: Integer, amount: Money).returns(Response)}
def forecast_and_lock(period:, amount:)
  raise ArgumentError, "Amount(#{amount}) must be positive." unless amount.positive?
  raise ArgumentError, "Period(#{period}) must be in the future." unless future_period?(period)
  # all the rest from previous slides
end

Modeling Business outcomes as sorbet types

Benefits

  1. Dev make explicit choices of what cases are business cases that need to be handled vs edge cases we don't support (yet?).
  2. Impact of new responses can be raised during type checking.
    • Example: if forecasting too far into the future is deemed too unreliable we may return that as a type as well.
  3. A service signature communicates (and enforces) known outcomes.
  4. Exceptions are only uses for truly broken cases, so we should never have to catch them.
    • If your code needs to catch exceptions, than they are not exceptions, they are edge cases we need to handle and typed do a better job of making sure that is clear.

Modeling Business outcomes as sorbet types

Downsides

  1. Sorbet is not build into ruby so must be run with: bundle exec srb tc --lsp --enable-all-beta-lsp-features to highlight where it stops type checking early (even in strict files).
  2. Thinking in types this way is not ruby native so might need some time to get used to.
  3. This are just my design ideas and there is tension between exceptions and response types.

Links

Real example

packs/servicing/loan/app/lib/services/estimate_and_lock_in_next_period_cmi.rb

Presentation

https://gist.github.com/Velrok/8e8338e0bf3f19c8c0ae01776054d96d or https://tinyurl.com/3d6xsxh5

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment