At a certain point in time we want to lock a CMI period to a forecasted amount.
- A lock already exists
- We lock in the same CMI
- We lock in a different CMI
- the period is in the past
- the forecasting encounters issues
- A lock already exists
- We lock in the same CMI
- We lock in a different CMI
# 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) }
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
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
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
- Dev make explicit choices of what cases are business cases that need to be handled vs edge cases we don't support (yet?).
- 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.
- A service signature communicates (and enforces) known outcomes.
- 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.
- 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). - Thinking in types this way is not ruby native so might need some time to get used to.
- This are just my design ideas and there is tension between exceptions and response types.
packs/servicing/loan/app/lib/services/estimate_and_lock_in_next_period_cmi.rb
https://gist.github.com/Velrok/8e8338e0bf3f19c8c0ae01776054d96d or https://tinyurl.com/3d6xsxh5