Make the BIG Change
(One SMALL Change at a Time)
Chris Geihsler
@seejee
Hi, I'm Chris
SOFTWARE is CRUFTY
BIG changes are HARD
CHANGE it SAFELY
ONE
COOL
TRICK
TELL a STORY
TAKE something AWAY
3 million STUDENTS
ROSTERS change
SIS
SIS
SIS
SIS
SIS
SIS
SIS
SIS
SIS
SIS
2013
+
PROBLEM?
120,000
SUPPORT:
3 DEVELOPER-YEARS
GOAL
Drastically reduce Clever-related support burden.
DESIGN cruft
TAKEAWAY
Protect your data from 3rd party integrations.
SOLUTION
Know what a Clever sync will change without making any changes.
Summary of APPLIED Changes
¯\_(ツ)_/¯
Summary of PROPOSED Changes
SYNC
CODE cruft
GOAL
Lay the foundation for future integrations.
PHASE 0:
High Level Plan
SYNC
ACTIONS
Student::Create
Student::ReassignSchool
Classroom::AddStudentToClassroom
Error::ValidationError
Teacher::Update
School::Deactivate
Error::WontAddStudentToClassroom
Error::CantMoveSchool
AcrossCustomers
PLAN
Generate a list of actions that can be invoked later.
INCREMENTAL
or
REWRITE?
REQUIREMENTS?
BIGGER CHANGES
BIGGER BREAKS
TAKEAWAY
Prefer incremental approach unless you have a high tolerance for risk.
PROCESS?
11 TEAM MEMBERS
23.6 PRs/WEEK
2.1 PRs/WEEK/PERSON
TAKEAWAY
Make it easy to test and deploy small, isolated changes.
5 PHASES
~5 PRs/PHASE
TAKEAWAY
Planning is more important than the plan itself.
TAKEAWAY
Big changes are easier when your stakeholders buy-in.
PHASE 1:
Extract and Isolate
class Student
# 1100 lines
def update_from_clever(clever_student)
# 100 lines
end
end
module CleverUtils
# 500 lines
def create_student(clever_student)
# 50 lines
end
end
class Sync::Student
def sync(clever_student)
student = find_student(clever_student)
if student.nil?
create(student, clever_student)
else
update(student, clever_student)
end
end
# more methods
end
class Student
#
# a little smaller
#
end
module CleverUtils
#
# a little smaller
#
end
TAKEAWAY
Complexity becomes apparent when code is in small, isolated pieces.
TAKEAWAY
You can't fix everything at once.
PHASE 1:
6 Pull Requests
~ 4 weeks
TANGENT:
TESTING
80% INTEGRATION
20% UNIT
SLOWED BY SLOW TESTS
TAKEAWAY
Refactor your tests as aggressively as your production code.
TAKEAWAY
Delete tests that provide minimal value.
PHASE 2:
Introduce Actions
class Sync::Classroom
def update(classroom, clever_classroom)
# do some update stuff
if !classroom.students.include?(student)
classroom.add_student(student)
end
end
end
class Sync::Classroom
def update(classroom, clever_classroom)
actions = []
# generate some actions
actions
end
end
# new
actions <<
Actions::AddStudentToClassroom.new(
student_id: student.id
classroom_id: classroom.id
)
# original
classroom.add_student(student)
class Actions::AddStudentToClassroom
attr_reader :student_id, :classroom_id
def invoke
student = Student.find(student_id)
classroom = Classroom.find(classroom_id)
classroom.add_student(student)
end
end
# original
def sync_students
clever_students.each do |clever_student|
Sync::Student.new.sync(clever_student)
end
end
# new
def sync_students
actions =
clever_students.flat_map { |clever_student|
Sync::Student.new.sync(clever_student)
}
actions.each do |action|
action.invoke
end
end
TAKEAWAY
Decoupling decisions from actions will improve system flexibility.
PHASE 2:
7 Pull Requests
~ 3 weeks
PHASE 3:
Serialize Actions
id | type | payload | invoked_at |
1 | action | #JSON | 2015-05-05 |
2 | action | #JSON | NULL |
3 | error | #JSON | NULL |
class Actions::AddStudentToClassroom
attr_reader :student_id, :classroom_id
def to_json
{
_class: 'AddStudentToClassroom',
student_id: student_id,
classroom_id: classroom_id
}
end
end
def sync_students
actions =
clever_students.map { |clever_student|
Sync::Student.new.sync(clever_student)
}
save_actions(actions)
invoke_pending_actions
end
TAKEAWAY
Interim states won't be ideal. That's OK.
PHASE 3:
3 Pull Requests
~ 2 weeks
PHASE 4:
Defer Actions
class Synchronizer
def sync
save_actions(sync_schools)
invoke_pending_actions
save_actions(sync_teachers)
invoke_pending_actions
save_actions(sync_students)
invoke_pending_actions
save_actions(sync_classrooms)
invoke_pending_actions
end
end
class Synchronizer
def sync
save_actions(sync_schools)
save_actions(sync_teachers)
save_actions(sync_students)
save_actions(sync_classrooms)
end
def invoke
invoke_pending_actions
end
end
Student::Create
PENDING ACTIONS
Classroom::Create
actions <<
Actions::AddStudentToClassroom.new(
student_id: student.id
classroom_id: classroom.id
)
PROBLEM
How do we refer to things that don't exist yet?
class EntityReference
def to_json
[
{ field: 'id' , value: XXXX},
{ field: 'clever_id', value: YYYY}
]
end
def find
end
def ==(other)
end
end
# new
if !current_student_refs.include?(student_ref)
actions <<
Actions::AddStudentToClassroom.new(
student_ref: student_ref
classroom_ref: classroom_ref
)
end
# original
if !classroom.students.include?(student)
actions <<
Actions::AddStudentToClassroom.new(
student_id: student.id
classroom_id: classroom.id
)
end
class Actions::AddStudentToClassroom
attr_reader :student_ref, :classroom_ref
def invoke
student = student_ref.find
classroom = classroom_ref.find
classroom.add_student(student)
end
end
PROBLEM
How do we account for pending changes?
if student.school == classroom.school
Actions::AddStudentToClassroom.new(
student_ref: student_ref
classroom_ref: classroom_ref
)
else
Errors::WontAssignClassroom.new(
student_ref: student_ref
classroom_ref: classroom_ref
)
end
Student::Update
Student::ReassignSchool
PENDING ACTIONS
Student
TTM MODEL
if student.school == classroom.school
Actions::AddStudentToClassroom.new(
student_ref: student_ref
classroom_ref: classroom_ref
)
else
Errors::WontAssignClassroom.new(
student_ref: student_ref
classroom_ref: classroom_ref
)
end
Student::Update
Student::ReassignSchool
Student
+
+
=
Pending Student State
class Data::Student
:attributes,
:school_ref,
:classroom_refs
end
def pending_state_for(student_ref)
initial_state =
if student_ref.exists?
extract_state(student_ref.find)
else
empty
end
pending_actions_for(student_ref)
.reduce(initial_state) { |state, action|
action.apply(state)
}
end
class Actions::Student::ReassignSchool
attr_reader :student_ref, :school_ref
def apply(current_state)
current_state.merge(
school_ref: school_ref
)
end
def invoke
student.reassign_school(school)
end
end
ONE
COOL
TRICK
state = f(existingModel, pendingActions)
newActions = f(cleverData, state)
summary = f(actions)
TAKEAWAY
Functional programming has broad applications.
TAKEAWAY
You don't need to use a functional language to program in a functional style.
PHASE 4:
7 Pull Requests
~ 4 weeks
PHASE 5:
Decouple from Clever
class EntityReference
end
class Data::Student
:attributes,
:school_ref,
:classroom_refs
end
class Sync::Clever::Student
def find_entity(clever_student)
EntityReference.new(clever_id: clever_student.id)
end
def extract(clever_student)
Data::Student.new(
attributes: {
first_name: clever_student[:first_name],
last_name: clever_student[:last_name],
},
school_ref: ...
)
end
end
PHASE 5:
2 Pull Requests
~ 1 week
PHASE 6:
Implement new CSV format
PHASE 6:
4 Pull Requests
~ 2 week
PRESENT:
Is it better?
5 BUGS
~24h TURNAROUND
PREVENTED ISSUES
SUPPORT:
FUTURE:
Implement
FUTURE:
Circuit Breaker
if will_too_much_stuff_change?(pending_actions)
reject_changes
pause_syncing
notify_support
end
FUTURE:
Undo
TAKEAWAY
Treating mutations as data has lots of cool benefits.
TEAM
TAKEAWAY
Diverse teams make better products.
MORE Capable
Maintainable
Extensible
-
LIGHTWEIGHT PLAN
-
STAKEHOLDER BUY-IN
-
SMALL CHANGES
-
TESTS, TESTS, TESTS
-
FUNCTIONAL APPROACH
-
DIVERSE TEAM
TAKEAWAYS
CONQUER your CRUFT
TELL us a STORY
THANKS!
Chris Geihsler
@seejee
make the big change
By Chris Geihsler
make the big change
- 2,426