');--input-focus-border-color:Highlight;--input-focus-outline:1px solid Canvas;--input-unfocused-border-color:transparent;--input-disabled-border-color:transparent;--input-hover-border-color:#000}@media (forced-colors:active){:root{--input-focus-border-color:CanvasText;--input-unfocused-border-color:ActiveText;--input-disabled-border-color:GrayText;--input-hover-border-color:Highlight}.annotationLayer .buttonWidgetAnnotation.checkBox input:required,.annotationLayer .buttonWidgetAnnotation.radioButton input:required,.annotationLayer .choiceWidgetAnnotation select:required,.annotationLayer .textWidgetAnnotation input:required,.annotationLayer .textWidgetAnnotation textarea:required{outline:1.5px solid selectedItem}}.annotationLayer{--scale-factor:1;left:0;pointer-events:none;position:absolute;top:0;transform-origin:0 0}.annotationLayer section{box-sizing:border-box;pointer-events:auto;position:absolute;text-align:initial;transform-origin:0 0}.annotationLayer .buttonWidgetAnnotation.pushButton>a,.annotationLayer .linkAnnotation>a{font-size:1em;height:100%;left:0;position:absolute;top:0;width:100%}.annotationLayer .buttonWidgetAnnotation.pushButton>canvas{height:100%;width:100%}.annotationLayer .buttonWidgetAnnotation.pushButton>a:hover,.annotationLayer .linkAnnotation>a:hover{background:#ff0;box-shadow:0 2px 10px #ff0;opacity:.2}.annotationLayer .textAnnotation img{cursor:pointer;height:100%;position:absolute;width:100%}.annotationLayer .buttonWidgetAnnotation.checkBox input,.annotationLayer .buttonWidgetAnnotation.radioButton input,.annotationLayer .choiceWidgetAnnotation select,.annotationLayer .textWidgetAnnotation input,.annotationLayer .textWidgetAnnotation textarea{background-image:var(--annotation-unfocused-field-background);border:2px solid var(--input-unfocused-border-color);box-sizing:border-box;font:calc(9px*var(--scale-factor)) sans-serif;height:100%;margin:0;vertical-align:top;width:100%}.annotationLayer .buttonWidgetAnnotation.checkBox input:required,.annotationLayer .buttonWidgetAnnotation.radioButton input:required,.annotationLayer .choiceWidgetAnnotation select:required,.annotationLayer .textWidgetAnnotation input:required,.annotationLayer .textWidgetAnnotation textarea:required{outline:1.5px solid red}.annotationLayer .choiceWidgetAnnotation select option{padding:0}.annotationLayer .buttonWidgetAnnotation.radioButton input{border-radius:50%}.annotationLayer .textWidgetAnnotation textarea{resize:none}.annotationLayer .buttonWidgetAnnotation.checkBox input[disabled],.annotationLayer .buttonWidgetAnnotation.radioButton input[disabled],.annotationLayer .choiceWidgetAnnotation select[disabled],.annotationLayer .textWidgetAnnotation input[disabled],.annotationLayer .textWidgetAnnotation textarea[disabled]{background:none;border:2px solid var(--input-disabled-border-color);cursor:not-allowed}.annotationLayer .buttonWidgetAnnotation.checkBox input:hover,.annotationLayer .buttonWidgetAnnotation.radioButton input:hover,.annotationLayer .choiceWidgetAnnotation select:hover,.annotationLayer .textWidgetAnnotation input:hover,.annotationLayer .textWidgetAnnotation textarea:hover{border:2px solid var(--input-hover-border-color)}.annotationLayer .buttonWidgetAnnotation.checkBox input:hover,.annotationLayer .choiceWidgetAnnotation select:hover,.annotationLayer .textWidgetAnnotation input:hover,.annotationLayer .textWidgetAnnotation textarea:hover{border-radius:2px}.annotationLayer .choiceWidgetAnnotation select:focus,.annotationLayer .textWidgetAnnotation input:focus,.annotationLayer .textWidgetAnnotation textarea:focus{background:none;border:2px solid var(--input-focus-border-color);border-radius:2px;outline:var(--input-focus-outline)}.annotationLayer .buttonWidgetAnnotation.checkBox :focus,.annotationLayer .buttonWidgetAnnotation.radioButton :focus{background-color:transparent;background-image:none}.annotationLayer .buttonWidgetAnnotation.checkBox :focus{border:2px solid var(--input-focus-border-color);border-radius:2px;outline:var(--input-focus-outline)}.annotationLayer .buttonWidgetAnnotation.radioButton :focus{border:2px solid var(--input-focus-border-color);outline:var(--input-focus-outline)}.annotationLayer .buttonWidgetAnnotation.checkBox input:checked:after,.annotationLayer .buttonWidgetAnnotation.checkBox input:checked:before,.annotationLayer .buttonWidgetAnnotation.radioButton input:checked:before{background-color:CanvasText;content:"";display:block;position:absolute}.annotationLayer .buttonWidgetAnnotation.checkBox input:checked:after,.annotationLayer .buttonWidgetAnnotation.checkBox input:checked:before{height:80%;left:45%;width:1px}.annotationLayer .buttonWidgetAnnotation.checkBox input:checked:before{transform:rotate(45deg)}.annotationLayer .buttonWidgetAnnotation.checkBox input:checked:after{transform:rotate(-45deg)}.annotationLayer .buttonWidgetAnnotation.radioButton input:checked:before{border-radius:50%;height:50%;left:30%;top:20%;width:50%}.annotationLayer .textWidgetAnnotation input.comb{font-family:monospace;padding-left:2px;padding-right:0}.annotationLayer .textWidgetAnnotation input.comb:focus{width:103%}.annotationLayer .buttonWidgetAnnotation.checkBox input,.annotationLayer .buttonWidgetAnnotation.radioButton input{appearance:none}.annotationLayer .popupTriggerArea{height:100%;width:100%}.annotationLayer .popupWrapper{font-size:calc(9px*var(--scale-factor));min-width:calc(180px*var(--scale-factor));pointer-events:none;position:absolute;width:100%}.annotationLayer .popup{word-wrap:break-word;background-color:#ff9;border-radius:calc(2px*var(--scale-factor));box-shadow:0 calc(2px*var(--scale-factor)) calc(5px*var(--scale-factor)) #888;cursor:pointer;font:message-box;margin-left:calc(5px*var(--scale-factor));max-width:calc(180px*var(--scale-factor));padding:calc(6px*var(--scale-factor));pointer-events:auto;position:absolute;white-space:normal}.annotationLayer .popup>*{font-size:calc(9px*var(--scale-factor))}.annotationLayer .popup h1{display:inline-block}.annotationLayer .popupDate{display:inline-block;margin-left:calc(5px*var(--scale-factor))}.annotationLayer .popupContent{border-top:1px solid #333;margin-top:calc(2px*var(--scale-factor));padding-top:calc(2px*var(--scale-factor))}.annotationLayer .richText>*{font-size:calc(9px*var(--scale-factor));white-space:pre-wrap}.annotationLayer .caretAnnotation,.annotationLayer .circleAnnotation svg ellipse,.annotationLayer .fileAttachmentAnnotation,.annotationLayer .freeTextAnnotation,.annotationLayer .highlightAnnotation,.annotationLayer .inkAnnotation svg polyline,.annotationLayer .lineAnnotation svg line,.annotationLayer .polygonAnnotation svg polygon,.annotationLayer .polylineAnnotation svg polyline,.annotationLayer .squareAnnotation svg rect,.annotationLayer .squigglyAnnotation,.annotationLayer .stampAnnotation,.annotationLayer .strikeoutAnnotation,.annotationLayer .underlineAnnotation{cursor:pointer}.annotationLayer .annotationTextContent,.annotationLayer section svg{height:100%;position:absolute;width:100%}.annotationLayer .annotationTextContent{color:transparent;opacity:0;pointer-events:none;-webkit-user-select:none;user-select:none}.annotationLayer .annotationTextContent span{display:inline-block;width:100%}@font-face{font-display:swap;font-family:Averta Web;font-style:normal;font-weight:400;src:url(/content-platform/static/Averta-Regular-fd1297bf462e95eea6aa118597d8ef9e.otf) format("opentype")}@font-face{font-display:swap;font-family:Averta Web;font-style:italic;font-weight:400;src:url(/content-platform/static/Averta-RegularItalic-a93ae4a14814b0363207932e2c55e7bb.otf) format("opentype")}@font-face{font-display:swap;font-family:Averta Web;font-style:normal;font-weight:600;src:url(/content-platform/static/Averta-Semibold-3cbea55cef956724c095d3faa96ac883.otf) format("opentype")}@font-face{font-display:swap;font-family:Averta Web;font-style:italic;font-weight:600;src:url(/content-platform/static/Averta-SemiboldItalic-7962116f4e3c788c3df34f71cb41f4c1.otf) format("opentype")}@font-face{font-display:swap;font-family:"PT Serif";font-style:normal;font-weight:400;src:url(/content-platform/static/PT_Serif-Web-Regular-a12d02e92a52545c0e252cb52e59a199.ttf) format("truetype")}@font-face{font-display:swap;font-family:"PT Serif";font-style:italic;font-weight:400;src:url(/content-platform/static/PT_Serif-Web-Italic-fbc44e1e589cb892ee02348b2a8a348c.ttf) format("truetype")}@font-face{font-display:swap;font-family:"PT Serif";font-style:normal;font-weight:600;src:url(/content-platform/static/PT_Serif-Web-Bold-f86a6a1831027140ee56a79917cdd50d.ttf) format("truetype")}@font-face{font-display:swap;font-family:"PT Serif";font-style:italic;font-weight:600;src:url(/content-platform/static/PT_Serif-Web-BoldItalic-dc0d330f07cb434d3df2ada512eeb3b3.ttf) format("truetype")}@font-face{font-display:swap;font-family:Simplon Mono;font-style:normal;font-weight:400;src:url(/content-platform/static/SimplonMono-Regular-f7db758b4f03c8b9179c1d8fe0bb44f8.woff2) format("woff2"),url(/content-platform/static/SimplonMono-Regular-4873b89276459e0daae704dc30e2966d.woff) format("woff")}@font-face{font-display:swap;font-family:Haffer;font-style:normal;font-weight:400;src:url(/content-platform/static/HafferXH-Regular-5aabd3cd7105e25df233f608f2d66bc2.otf) format("opentype")}@font-face{font-display:swap;font-family:Haffer;font-style:italic;font-weight:400;src:url(/content-platform/static/HafferXH-RegularItalic-8cc7af3f0d75ba1b610a1b1878ea5eee.otf) format("opentype")}@font-face{font-display:swap;font-family:Haffer;font-style:normal;font-weight:500;src:url(/content-platform/static/HafferXH-Medium-dae5509349255a527ba6223b40257a7c.otf) format("opentype")}@font-face{font-display:swap;font-family:Haffer;font-style:normal;font-weight:600;src:url(/content-platform/static/HafferXH-SemiBold-64a01291b8f7380359bb3642c1b1e00a.otf) format("opentype")}@font-face{font-display:swap;font-family:Haffer;font-style:italic;font-weight:600;src:url(/content-platform/static/HafferXH-SemiBoldItalic-869dd4deb4c6e290c1aaf1e6a8670036.otf) format("opentype")}@font-face{font-display:swap;font-family:Nudge;font-style:normal;font-weight:100;src:url(/content-platform/static/VCNudge-Thin-09f2315fb254f9dc017f0e4983166415.otf) format("opentype")}@font-face{font-display:swap;font-family:Nudge;font-style:normal;font-weight:300;src:url(/content-platform/static/VCNudge-Light-dc44d09f0564da3a5f46801b8bb26cd8.otf) format("opentype")}@font-face{font-display:swap;font-family:Nudge;font-style:normal;font-weight:400;src:url(/content-platform/static/VCNudge-Regular-be0a147c918c6bec670c17f5204784bb.otf) format("opentype")}@font-face{font-display:swap;font-family:Nudge;font-style:normal;font-weight:700;src:url(/content-platform/static/VCNudge-Bold-822d17e33e72d439ad42d99a75ae6df0.otf) format("opentype")}@font-face{font-display:swap;font-family:Nudge;font-style:normal;font-weight:800;src:url(/content-platform/static/VCNudge-ExtraBold-a2d0723b460f32bc659ad1670a00ae22.otf) format("opentype")}@font-face{font-display:swap;font-family:Nudge;font-style:normal;font-weight:900;src:url(/content-platform/static/VCNudge-Black-9e870be920be60c228f9a4affa885f8d.otf) format("opentype")}@font-face{font-display:swap;font-family:Para;font-style:normal;font-weight:300;src:url(/content-platform/static/Para-Central-Light-b0a58901815efc51b2b07b595eca45f7.otf) format("opentype")}@font-face{font-display:swap;font-family:Para;font-style:italic;font-weight:300;src:url(/content-platform/static/Para-Central-Light-Italic-48e4a958a048c612c6a0927f538018b8.otf) format("opentype")}@font-face{font-display:swap;font-family:Para;font-style:normal;font-weight:400;src:url(/content-platform/static/Para-Central-Regular-03a3a49a91a9bbf991dd6263feab3975.otf) format("opentype")}@font-face{font-display:swap;font-family:Para;font-style:normal;font-weight:700;src:url(/content-platform/static/Para-Central-Bold-3107caa8a871d45bb00cb084bcc8ffc1.otf) format("opentype")}@font-face{font-display:swap;font-family:DM Mono;font-style:normal;font-weight:300;src:url(/content-platform/static/DMMono-Light-2cf6d0b2de012ff294ce86eecb4263e5.ttf) format("truetype")}@font-face{font-display:swap;font-family:DM Mono;font-style:italic;font-weight:300;src:url(/content-platform/static/DMMono-LightItalic-0e20bf0432fcf702ef0306184759a593.ttf) format("truetype")}@font-face{font-display:swap;font-family:DM Mono;font-style:normal;font-weight:400;src:url(/content-platform/static/DMMono-Regular-15edd89a6460acfb1a86017399e47a1f.ttf) format("truetype")}@font-face{font-display:swap;font-family:DM Mono;font-style:italic;font-weight:400;src:url(/content-platform/static/DMMono-Italic-6b7b77e96ef2aa8ec8fffe2e28239719.ttf) format("truetype")}@font-face{font-display:swap;font-family:DM Mono;font-style:normal;font-weight:500;src:url(/content-platform/static/DMMono-Medium-50d7af0bb966bc0570a212128d7ab24d.ttf) format("truetype")}@font-face{font-display:swap;font-family:DM Mono;font-style:italic;font-weight:500;src:url(/content-platform/static/DMMono-MediumItalic-85bff8703124566bb1a660afda35365b.ttf) format("truetype")}body,html{box-sizing:border-box;margin:0;padding:0}code[class*=language-],pre[class*=language-]{word-wrap:normal;background:none;color:#000;font-family:Consolas,Monaco,Andale Mono,Ubuntu Mono,monospace;font-size:1em;-webkit-hyphens:none;hyphens:none;line-height:1.5;tab-size:4;text-align:left;text-shadow:0 1px #fff;white-space:pre;word-break:normal;word-spacing:normal}code[class*=language-] ::selection,code[class*=language-]::selection,pre[class*=language-] ::selection,pre[class*=language-]::selection{background:#b3d4fc;text-shadow:none}@media print{code[class*=language-],pre[class*=language-]{text-shadow:none}}pre[class*=language-]{margin:.5em 0;overflow:auto;padding:1em}:not(pre)>code[class*=language-],pre[class*=language-]{background:#f5f2f0}:not(pre)>code[class*=language-]{border-radius:.3em;padding:.1em;white-space:normal}.token.cdata,.token.comment,.token.doctype,.token.prolog{color:#708090}.token.punctuation{color:#999}.token.namespace{opacity:.7}.token.boolean,.token.constant,.token.deleted,.token.number,.token.property,.token.symbol,.token.tag{color:#905}.token.attr-name,.token.builtin,.token.char,.token.inserted,.token.selector,.token.string{color:#690}.language-css .token.string,.style .token.string,.token.entity,.token.operator,.token.url{background:hsla(0,0%,100%,.5);color:#9a6e3a}.token.atrule,.token.attr-value,.token.keyword{color:#07a}.token.class-name,.token.function{color:#dd4a68}.token.important,.token.regex,.token.variable{color:#e90}.token.bold,.token.important{font-weight:700}.token.italic{font-style:italic}.token.entity{cursor:help}
Statesman: A modern, robust Ruby state machine | GoCardless Skip to content
Statesman: A modern, robust Ruby state machine There's no shortage of Ruby state machine libraries, but when we needed to implement a formal state machine we didn't find one which met all of our requirements:
Easily composable with other Ruby objects. We need to define a state machine as a separate class and selectively apply it to our Rails models.
DB level data integrity. We run multiple application servers, so state change related race conditions should be prevented by a database constraint.
Full audit history of state transitions. We needed to persist transitions to the database and include unstructured metadata with each transition.
Today, we are pleased to release Statesman , the state machine library which we wish had existed.
Statesman is extremely opinionated and is designed to provide a robust audit trail and data integrity. It decouples the state machine logic from the underlying model and allows for easy composition with one or more model classes.
It provides the following features:
Database persisted transitions for a full audit history.
Database level transition duplication protection.
Transition metadata - store any JSON along with a transition.
Decoupled state machine logic
How it works
A state machine might look like this:
class PaymentStateMachine
include Statesman:: Machine
state :pending , initial : :true
state :confirmed
state :paid
state :cancelled
transition from: :pending , to : [ :confirmed , :cancelled ]
transition from: :confirmed , to : :paid
end
This class expects a model instance and a transition class to be injected on instantiation. Let's assume we have ActiveRecord models set up like so:
class Payment < ActiveRecord:: Base
has_many :payment_transitions
end
class PaymentTransition < ActiveRecord:: Base
belongs_to :payment
end
We can set up a new state machine easily:
payment = Payment. find( some_id)
machine = PaymentStateMachine . new ( payment, transition_class : PaymentTransition)
And call a few methods:
machine. current_state
machine. can_transition_to? ( :confirmed )
machine. transition_to! ( :confirmed )
machine. current_state
Under the hood statesman will create, associate and persist a new PaymentTransition
object. When we send current_state
to the machine
object it queries associated transitions and returns the state of the most recent. This is fully normalized, state is not stored on the parent Payment
model.
Guards & Callbacks
Often, the current state is not the only factor to determine whether a new state can be applied. Statesman supports guards which should return true
or false
, a false return value will prevent a transition.
guard_transition( from : :pending , to : :submitted ) do | payment|
payment. passed_fraud_check?
end
machine. can_transition_to? ( :confirmed )
machine. transition_to! ( :confirmed )
Callbacks are defined in the same way as guards and allow extra actions to be performed before and after a state transition:
before_transition( to : :cancelled ) do | payment, payment_transition|
payment. prepare_for_cancellation!
end
after_transition( to : :cancelled ) do | payment, payment_transition|
PaymentMailer. cancelled_payment( payment. id) . deliver
end
Storage Adaptors
The examples show a Rails app using ActiveRecord, but there's also an adapter for Mongo, and an in-memory adaptor out of the box. An adaptor need only prove a small number of methods to be compatible so it should be very easy to implement for your favourite ORM. A set of Rspec examples are provided - pass these and your adapter should work just fine. Pull requests are very welcome!
Getting started
Statesman includes two rails generators - one to add columns to an existing transition class and one to create a new table and transition class. Both expect to be passed the parent model name and the transition model name:
$ rails generate statesman:active_record_transition Payment PaymentTransition
$ rails generate statesman:mongoid_transition Payment PaymentTransition
$ rails generate statesman:migration Payment PaymentTransition
Summary
We've been using Statesman in production for a while now and have been extremely happy with the results. We've removed hundreds of lines of ad-hoc state checking code and have really enjoyed the benefit of decoupling state machine logic from our data models.
Statesman is available via RubyGems and the source is on GitHub . Suggestions and contributions are very welcome, please open an issue or pull request, or just sent me a tweet @appltn .
Over 100,000 businesses use GoCardless to get paid on time. Learn more about how you can improve payment processing at your business today. Interested in automating the way you get paid? GoCardless can help Contact us Seen 'GoCardless Ltd' on your bank statement? Learn more
GoCardless Ltd, Sutton Yard, 65 Goswell Road, London, EC1V 7EN, United Kingdom
GoCardless Ltd (company registration number 07495895) is authorised by the Financial Conduct Authority under the Payment Services Regulations 2017, registration number 597190, for the provision of payment services.