Upgrade to Pro — share decks privately, control downloads, hide ads and more …

In Limbo: Managing Transitional States

In Limbo: Managing Transitional States

"We're switching to a new CSS framework." "Billing needs to be decoupled from the user model." "The team has decided to change authorization libraries." "We have to refactor that critical data process with low test coverage."

Over a web application's lifespan, many changes to code and data cannot (or should not) be made in one deployment. These complex, incremental transitions often need to be interleaved with feature development and other work.

How do developers manage these limbo states? How do teams ensure they arrive at their destination safely? In this talk, we'll investigate different transitions within Rails applications, including changes to dependencies, modeling/architecture, and data processing. We'll uncover some underlying principles and practices to help us succeed when managing transitions in our software systems.

Avatar for Jeremy Smith

Jeremy Smith

April 24, 2025
Tweet

More Decks by Jeremy Smith

Other Decks in Programming

Transcript

  1. In Limbo Lundy, Fastnet, Irish Sea Ive got a message

    I cant read Im lost at sea Dont bother me Ive lost my way
  2. Dependency Change gem acts_as_paranoid client .find( ) client.destroy client.persisted? client.deleted_at

    client .find( ) client.really_destroy! client.persisted? client .all client .with_deleted "paranoia" class < end >> = >> >> >> >> = >> >> >> = >> = Client ApplicationRecord Client 1 Client 2 Client Client # => true # => 2025-04-03 4:26:02 PM # => false # Only clients not soft-deleted # All clients
  3. Dependency Change class < end class < end >> =

    >> >> >> >> acts_as_paranoid has_many , belongs_to client .find( ) client.emails.size client.destroy client.persisted? client.reload.emails.size Client ApplicationRecord Email ApplicationRecord :emails dependent: :destroy :client Client 1 # => 74 # => true # => 0
  4. Dependency Change gem :: .discard_column client .find( ) client.discard client.persisted?

    client.deleted_at client .find( ) client.destroy client.persisted? client .all client .kept "discard" class < include = end >> = >> >> >> >> = >> >> >> = >> = Client ApplicationRecord Discard Model self :deleted_at Client 1 Client 2 Client Client # => true # => 2025-04-03 4:26:02 PM # => false # All clients # Only clients not soft-deleted
  5. Schema Change class < end enum , { , ,

    } User ApplicationRecord :role regular: manager: admin: "regular" "manager" "admin"
  6. Schema Change class < end class < end class <

    end class < end has_many belongs_to belongs_to has_many belongs_to belongs_to validates , , User ApplicationRecord Membership ApplicationRecord MembershipRole ApplicationRecord Role ApplicationRecord :memberships :user :team :membership_roles :membership :role :name presence: true uniqueness: true
  7. Data Process Change class < def end private def new

    end end has_one_attached update!( calculate_unique_word_count) . (plain_text, ).calculate Document ApplicationRecord update_metadata! calculate_unique_word_count :file unique_word_count: WordCountCalculator unique: true
  8. Data Process Change class def = = end def =

    ? end private attr_reader end (text, ) @text text.to_s @unique unique words text.split( ) unique words.uniq.count : words.count , WordCountCalculator initialize calculate unique: false :text :unique / / \s+
  9. Discovery * Which default components are being used? * What

    Bootstrap Javascript is being used? * Is the provided icon set being used, or is it another one? * Are there any Bootstrap extensions being used? * How has the framework been extended or modified? * How much custom CSS depends on the existence of Bootstrap classes? * What Bootstrap classes are being relied on for custom Javascript or tests?
  10. Inventory # Views # Partials # Helpers # Libraries app/views/layouts/application.html.erb

    app/views/dashboard/index.html.erb app/views/accounts/index.html.erb app/views/admin/accounts/index.html.erb app/views/admin/accounts/show.html.erb app/views/shared/_alert.html.erb app/views/shared/_footer.html.erb app/views/shared/_navbar.html.erb ActivityHelper#activity_model_classes ActivityHelper#activity_event_names simple_form-bootstrap bootstrap-toggle
  11. Preliminary Tasks <% admin_context? %> < >< = >Back to

    Dashboard</ ></ > <% %> < >< = >Admin</ ></ > <% %> if else end li a a li li a a li href href "<%= dashboard_path %>" "<%= admin_root_path %>"
  12. Preliminary Tasks module def ** ** end def & =

    do end = & end end (name, url, , options) tag.li(link_to(name, url, options), [ ]) (name, ) toggle link_to , , { } safe_join [name, tag.span( )] menu tag.ul( , ) tag.li(safe_join([toggle, menu]), ) NavigationHelper nav_link nav_dropdown active: false class: active: class: data: toggle: class: class: class: "#" "dropdown-toggle" "dropdown" "caret" "dropdown-menu" "dropdown"
  13. Preliminary Tasks // Form input event listener // … $

    ready $ on (document). ( () { ( ). ( , () { }); }); function function ".form-control" "input" # Feature spec using Capybara RSpec with: text: .feature scenario visit new_notes_path fill_in , click_button expect(page).to have_selector( , ) "Note management" "User creates a note" "body" "This is my new note." "Save" "div.alert-success" "Your note was saved." do do end end
  14. SEtup class < private def % || end end helper_method

    params[ ].presence_in( w[bootstrap tailwind]) ApplicationController ActionController::Base css_framework :css_framework :css_framework "bootstrap"
  15. SEtup < > < ><%= page_title %></ > < =

    > <%= javascript_include_tag %> <% css_framework %> <%= stylesheet_pack_tag %> <%= javascript_pack_tag , %> <% %> <% css_framework %> <%= stylesheet_link_tag %> <%= javascript_pack_tag %> <% %> </ > head title title meta head charset "utf-8" "application" "tailwind" "tailwind" "application" "tailwind" "bootstrap" "application" "application" <!-- ... --> <!-- ... --> if == end if == end
  16. SEtup # Partials # Helpers app/views/shared/_alert.html.erb app/views/shared/_footer.html.erb app/views/shared/_navbar.html.erb app/views/shared/bootstrap/_alert.html.erb app/views/shared/bootstrap/_footer.html.erb

    app/views/shared/bootstrap/_navbar.html.erb ActivityHelper#activity_model_classes ActivityHelper#activity_event_names ActivityHelper#bootstrap_activity_model_classes ActivityHelper#bootstrap_activity_event_names
  17. SEtup class < private def % || end end class

    < private def end end helper_method params[ ].presence_in( w[bootstrap tailwind]) ApplicationController ActionController::Base css_framework DashboardController ApplicationController css_framework :css_framework :css_framework "tailwind" "bootstrap" # ...
  18. SEtup class < def = super end def = super

    end private def end end (method, options {}) (method, options.merge( )) (method, options {}) (method, options.merge( )) TailwindFormBuilder ActionView::Helpers::FormBuilder text_field text_area default_classes class: :class class: :class "#{options[ ]} #{default_classes}" "#{options[ ]} #{default_classes}" "w-full border-gray-300 shadow-inner disabled:bg-gray-100 focus:ring-2" # ...
  19. SEtup class < end class < end default_form_builder default_form_builder ApplicationController

    ActionController::Base DashboardController ApplicationController TailwindFormBuilder BootstrapFormBuilder
  20. Discovery ### ChangeUserPlan Description: * Updates user plan, and category

    and name if necessary * Sets user expired_at to nil Called From: * SubscriptionsController#upgrade * StripeEvent#customer_subscription_created * ConvertAccountTypeWorker#process #### EndFreeTrialWorker Description: * Set user trial_ends_at to current time Called From: * Admin::UsersController#end_free_trial
  21. Discovery ### UpdateStripeEmailWorker If the user changes their email address,

    should this still update the billing email in Stripe? ### CreateStripeSubscriptionWorker This worker isn't called from anywhere in the system. Can we safely remove it?
  22. Inventory ### Data * users.stripe_customer_id * users.email * users.coupon *

    users.trial_ends_at * users.expired_at * users.billing_address ... ### Methods * StripeEventHandlers#charge_succeeded * StripeEventHandlers#customer_subscription_created * StripeEvent#update_trial * CreateStripeCustomer.call * CreateStripeSubscription.call ...
  23. Preliminary Tasks class def return if = return if =

    return if new end end (stripe_charge_id) stripe_charge_id.blank? stripe_customer_id :: .retrieve(stripe_charge_id).customer stripe_customer_id.blank? user .find_by( stripe_customer_id) user.locked_out? . .perform(user.id) .disputed_payment_email(user.id).deliver_now DisputedPaymentWorker perform Stripe Charge User stripe_customer_id: LockOutUserWorker SupportMailer
  24. SEtup class < def do end end end [ ]

    create_table |t| t.references , , , { } t.references , , t.references , , t.string , t.string t.datetime t.datetime t.string ... t.timestamps add_index , , CreateBillingAccounts ActiveRecord::Migration change 7.1 :billing_accounts :account null: false foreign_key: true index: unique: true :plan null: false foreign_key: true :address null: true foreign_key: true :coupon limit: 30 :stripe_customer_id :trial_ends_at :expired_at :email :billing_accounts :stripe_customer_id unique: true
  25. SEtup class < def return if end end belongs_to belongs_to

    has_one , , accepts_nested_attributes_for , account.admin_user.blank? update!( account.admin_user.coupon, account.admin_user.stripe_customer_id, account.admin_user.trial_ends_at, account.admin_user.expired_at, ) Billing::Account ApplicationRecord sync! :account :plan :address as: :addressable dependent: :destroy :address update_only: true false coupon: stripe_customer_id: trial_ends_at: expired_at: ...
  26. SEtup namespace desc task :: .in_batches.each_with_index |relation, batch_index| billing_account_ids relation.ids

    .perform_bulk(billing_account_ids.map { |id| [id] }) (billing_account_id) billing_account :: .find(billing_account_id) billing_account.sync! :billing_accounts sync_all: :environment Billing Account puts 1 SyncBillingAccountWorker Billing Account do do do = + end end end class def = end end "Sync all billing accounts" "Scheduling batch ##{batch_index }, size: #{billing_account_ids.size}" SyncBillingAccountWorker perform
  27. Dual write # Before # After # Remove on cleanup

    class def = = end end class def = = do end end end (user_id) user .find(user_id) customer .call( user) user.update!( customer.id) (account_id) account .find(account_id) customer .call( account.admin_user) :: .transaction account.billing_account.update!( customer.id) account.admin_user.update!( customer.id) CreateStripeCustomerWorker perform CreateStripeCustomerWorker perform User CreateStripeCustomer user: stripe_customer_id: Account CreateStripeCustomer user: ActiveRecord Base stripe_customer_id: stripe_customer_id:
  28. Change source of truth # Before # Remove on cleanup

    # After def return unless && = do end end def return unless && = end user user.expired_at.blank? expiration .current :: .transaction user.update!( expiration) billing_account.update!( expiration) billing_account billing_account.expired_at.blank? expiration .current ... customer_subscription_deleted customer_subscription_deleted Time ActiveRecord Base expired_at: expired_at: Time
  29. Remove original # Before # TODO: Remove on cleanup #

    After class def = do = end end end class def = end end (account_id) account .find(account_id) :: .transaction trial_end .current account.billing_account.update!( trial_end) account.admin_user.update!( trial_end) (account_id) account .find(account_id) account.billing_account.update!( .current) EndFreeTrialWorker perform EndFreeTrialWorker perform Account ActiveRecord Base Time trial_ends_at: trial_ends_at: Account trial_ends_at: Time
  30. Discrepancies class < end belongs_to belongs_to has_one , , normalizes

    , (email) { email.to_s.strip.downcase } ... Billing::Account ApplicationRecord :account :plan :address as: :addressable dependent: :destroy :email with: ->
  31. Why

  32. Until you make the unconscious conscious, it will direct your

    life and you will call it fate. Carl Jung
  33. Lundy, Fastnet, Irish Sea Ive got a message I cant

    read Im lost at sea Dont bother me Ive lost my way