As an engineer, stay in flow with “DemandAck”.
DemandAck is a coding abstraction that allows software engineers to handle the potentially destructive implications of user actions. It reduces design-burden and allows engineers to stay in flow.
Context
In the summer of 2021, OfficeTogether wanted to develop a new feature to permanently assign seats to individual employees.
At the time, OfficeTogether’s main product was a hotel-desking solution:
Employees reserve a desk before coming into the office. They can see which coworkers are coming in too, where are they sitting, and plan their weeks together.
Admins can ensure that employees have completed health checks and uploaded a vaccine card. Admins can also set capacity limits and monitor office analytics for their office space utilization.
The Problem
Let’s say you want to build a feature so that:
- Office admins can permanently assign seats to an individual employee,
- in a way that no other employee can reserve that seat.
If there are already existing future reservations on that seat, what should OfficeTogether do with those future reservations?
For example:
- Edgar has reserved Seat 42 for next Tuesday.
- Alice (an admin) wants to assign Seat 42 to Bob (another employee) starting today.
What should happen to Edgar’s reservation?
This problem is compounded by the fact that some companies in OfficeTogether allow their employees to reserve seats up to 6 months in advance. Some companies allow recurring reservations or “group” reservations too.
So, if Alice wants to assign Seat 42 to Bob, but there are potentially tens to hundreds of existing reservations for Seat 42, what should the system do?
What would you do? …
Options
Some high-level options that we could take are as follows:
- Restrict the action. We can disable Alice’s ability to assign Seat 42 if there are future reservations on that seat. We can force the admin (Alice) to go and delete all of the future reservations on Seat 42 manually, then to come back to the admin panel to make the assignment.
- Leave the future reservations on the seat. If an admin wants to, they can assign Seat 42 to Bob and then later go and delete Edgar’s future reservations. While this is reasonable, it creates an inconsistent “state” in our backend — e.g. Bob is permanently and exclusively assigned Seat 42 but somehow Edgar (and maybe others) have reservations on it. This might cause future product bugs.
- Destructively delete all future reservations. In the backend endpoint, we can force-delete all future reservations. Over time, admins will learn that the system is destructive. Over time, the system can become smarter and “smart” reassign existing reservations on the seat so that employees still have their capacity reserved.
- Something else…? There are plenty of other more elegant and complicated solutions, which would require more engineering work. Given that this is a simple edge-case, we don’t want to overdo it either.
What’s a short-term product solution?
In the short-term, we decided that the best solution was simply to show a confirmation dialog. So, when an admin “saves” a permanent seat assignment, if there are future reservations on that seat, we show a confirmation dialog allowing the admin to complete the action or to continue moving forward.
So, this makes sense product-wise, but there’s still an important question: how would you write the backend endpoint?
- Should the backend endpoint:
POST seat/42/assign?users=[bob]
simply assume that the confirmation dialog is the responsibility of the frontend? Should it destructively remove all future reservations? - Should the backend engineer also expose endpoints to
getFutureSeatConflicts(42)
or toremoveFutureSeatReservations(42)
so that the frontend engineer can call those? What if there’s a race condition between when future seat conflicts were removed and when theassignSeats()
backend is called? - What if a future UI framework (e.g. Mobile App, Slack App, Microsoft Teams App, Desktop App) wants to call the same backend API to
assignSeats()
, is it the responsibility of the frontend engineer to handle those data consistency checks? What if the way that the frontend checks for conflicts doesn’t match the way that the backend checks for conflicts? Will there be drift in what the confirmation dialogue shows and how the backend behaves?
All of these questions are difficult to answer, and we would like to centralize all of our database consistency checks into a single backend call for assignSeat()
.
This is where demandAck()
comes in.
DemandAck
OfficeTogether proposed a product-infra abstraction called demandAck()
. The abstraction allows backend engineers to make elegant, but destructive database writes, while also allowing users to acknowledge those actions easily.
Core Components
demandAck()
function. This is a sample from our backend code, where we trash reservations before assigning a seat.
We protect the destructive action ReservationsController.trashReservations()
by demanding an acknowledgment from the user first. The backend engineer only needs to provide what the “implication” of this action is going to be.
Behind the scenes, demandAck
looks for acknowledged implications in the ackContext.
If the specific implication string (in this case: Delete all future reservations...
) is not found in the ackContext
, then demandAck
will throw a MissingImplicationsError
on the request.
2. Replay architecture. On the OfficeTogether frontend, we have a error handler which catchesMissingImplicationsErrors
and displays the implication string(s) to the user. If the user decides to not proceed with the request, then the error will be propagated to the rest of the product code. If the user does confirm the request, the frontend will replay the exact same request to the backend with a X-OT-ACKNOWLEDGED-IMPLICATIONS
header and the implication that was acknowledged.
This then allows the backend to move past the same demandAck()
call because the ackContext
includes the acknowledged implication.
Product Infrastructure Engineers need to code O(n)
ways of handling MissingImplicationsError
, one for each frontend frameworks (e.g. web, mobile, internal tools). This is doable even in Slack and Teams Apps.
3. Acknowledgment Context. The acknowledgment context is dead simple. It is an object generated by all of the implications in the X-OT-ACKNOWLEDGED-IMPLICATIONS
header, and an option to bypass all demandAck()
calls. It is generated on every request to the OT backend.
Each demandAck()
call must start with an ackContext
as the first parameter, so theAckContext
needs to be passed through all functions until the actual destructive action is taken. This also allows backend engineers to understand which functions can even possibly throw a MissingImplicationsError
. It also allows engineers to bypass specific acknowledgement demands (e.g. when running backend cron jobs for cleanup or if a specific callsite doesn’t require it etc.,) by setting shouldBypassAllAcks
to true.
Bonus Features
4. PreAcknowledgments. If the frontend has a better UI for ensuring that a destructive action is safe (e.g. in our UI, we could’ve given the admin a checkbox which says: “Delete all future reservations when saving.”), they can always choose bypass specific implications by placing them into the header on the initial request. This allows the frontend to still create more appropriate UI than confirmation dialogs in a single initial request.
5. DoAllImplications. Finally, we also expose an abstraction called doAllImplications()
which allows backend engineers to batch multiple demandAck()
throws into a single exception. Otherwise, a single request might serially throw n
times based on the number of demandAcks()
called. When wrapped in a doAllImplications()
the destructive actions are acknowledged in parallel.
And, that’s all!
What do we get from this?
- We reduce the burden on designers. A lot of these edge cases will emerge at the code-level (not the product/design level). An engineer has access to database constraints, type systems, and other tools that force them to consider the laundry-list of potential edge cases. Letting them discover and handle these edge cases is more efficient than expecting designers to consider all interaction edge cases before “passing it off.” This allows designers to focus on the most important, core interactions. With DemandAck, potential errors and database inconsistencies simply become slightly annoying confirmation dialogs rather than bugs.
- Backend engineers get to stay in flow. As a lower priority, the backend engineer can discuss how to pre-acknowledge certain implications with the frontend engineer or even discuss how to handle this case with the designers and product managers. But they can at least ship a working version of backend endpoints without being blocked on other functions, while still maintaining the integrity of the DB. They have the power now to make this decision with low-risk of shipping a bad UX. If it’s a low-probability enough edge case such that the designer didn’t design for it, then leaving the
demandAck()
in there is probably fine even in the long-term. - Ultimately, this speeds up product velocity for the company.
We’ve been using this in production for about a year now with about ~20 demandAck()
calls in our codebase. We’ve implemented replay architecture on our core web frontend and our slack app (we don’t have a mobile app, but it would be easy to implement it there too). And, today, we can quickly create confirmation dialogs on the backend for potentially destructive (but ultimately more elegant) actions.
If you have any thoughts on how to improve this or anything we’re missing, please let me know!
Acknowledgments
- Special thanks to Alex Pedersen, our intern who coded most of the system and Nathan Li, the engineering lead who contributed to its architecture.
- Sebastian implemented the assigned seats frontend (exposing many of the edge cases) and Alex Ifrim designed the replay architecture for slack.
- Becca Olsson and Jon Prieto designed the assigned seats UX.
Willy is an Engineering Manager at OfficeTogether.