



We migrated ~40,000 devices from Firebase Cloud Messaging (FCM) to Amazon SNS using parallel change: three deployable phases, a dual-write repository and a lazy migration, with no coordinated cutover. This piece covers how we made it work, and what changes when providers do not accept the same device token.
Every production system eventually outgrows a third-party dependency. The question then is "how do we migrate without a service outage or a week of freeze-and-pray?"
In our case, the API was built with Django, and the outgrown dependency was Firebase Cloud Messaging (FCM) for push notifications, handled through a third-party package (fcm-django). The target was Amazon Simple Notification Service (SNS). On the surface, both send push notifications. Under the surface, they have different authentication models, different API shapes, and different notions of what identifies a device.
The naive approach is to swap the implementation, deploy, and cross your fingers. The risk is that notifications stop working for any device whose token hasn't been translated to an SNS endpoint ARN (Amazon Resource Name: AWS's unique identifier required to route a message to a specific device), and there is no way to do that translation without calling the SNS API. In this system that meant ~40,000 devices going silent in one deployment.
We chose another approach: the parallel change pattern (also called expand and contract) [1]. Because the application was built with clean architecture and Domain-Driven Design (DDD), the migration was almost mechanical.
Parallel change is a three-phase refactoring technique for making breaking infrastructure changes without a coordinated cutover:
Expand: add the new capability alongside the old one. Both can coexist. The system keeps working.
Migrate: gradually shift traffic, data, or both to the new path. The old path stays available, so switching back is always possible.
Contract: remove the old path once migration is complete.

Figure 1. Parallel Change flow
At no point is the system in a half-migrated state that breaks. Each phase is a deployable, production-safe unit of work [1].
In the platform's domain layer, the entire push notification concern is expressed as a single interface:
PYTHON
class PushNotificationService(ABC):
@abstractmethod
def create_external_id(self, registration_id: str) -> str:
raise NotImplementedError
@abstractmethod
def send(self, notification: str, recipients: list) -> None:
raise NotImplementedError
The create_external_id method is called once at device registration time and returns the identifier the provider needs to address that device. The send method uses it when delivering notifications. Every use case, subscriber, and job handler depends on this interface. None of them import Firebase or boto3. None of them know what a Platform Endpoint ARN is.
This is the point: once this interface is in place, replacing Firebase with SNS leaves the application layer with nothing to change. Compare this to the common alternative, where use cases call the FCM SDK directly: in that case, migrating to SNS means touching every caller. You are doing a rewrite under pressure.
The Device domain model needed one new field to support the migration:
PYTHON
class Device:
def __init__(
self,
user_id: str,
registration_id: str, # FCM device token (legacy, provider-specific)
device_id: str,
device_type: str,
name: str,
external_id: str = None #
provider-agnostic identifier, nullable for legacy devices
):
...
The external_id field is always derived from registration_id by calling create_external_id on the service. For the Firebase implementation, external_id equals registration_id directly, since FCM addresses devices by their token. For the SNS implementation, external_id is the Platform Endpoint ARN that SNS assigns when the token is registered, a value that must be obtained by calling the SNS API and cannot be computed locally. During the migration both are kept: FCMDevice still holds the registration_id Firebase sends to, and DeviceModel holds the SNS external_id, so switching back to Firebase needs no data changes.
The external_id field is nullable only to accommodate legacy devices that existed before the field was introduced. All new devices have it set at registration time. The domain model does not know or care what the value contains: that interpretation belongs to whichever service implementation is in use. In hindsight, naming the field registration_id coupled the domain to FCM; adding external_id and create_external_id was the one change outside infrastructure. With a provider-agnostic external_id from the start, a future migration only needs an external_id_v2, renamed once it is done.
The third-party FCM model cannot have columns added to it. Rather than a companion table (fragile, requires joins, leaks the transition detail), we created a fully-owned DeviceModel with all required fields including external_id.
The repository handles both models transparently. Until the contract phase, writes go to both. Reads check DeviceModel first; if no record exists there, the repository falls back to FCMDevice and returns the device with external_id unset. The DeviceModel entry for that legacy device is then created on its first notification send, when the service resolves the external ID (see the next section). Application code calls device_repository.save() or reads through the repository with no awareness that two tables exist underneath. This is the repository pattern doing exactly what it is supposed to do.
New devices always have external_id set at registration time, via create_external_id. Legacy devices that existed before the field was introduced do not: the repository returns them with external_id unset. SnsPushNotificationService resolves this on every send before publishing, so sends work without any bulk pre-population:
PYTHON
def resolve_external_id(device):
if device.external_id is not None:
return device.external_id
external_id = create_external_id
device.registration_id)
device.external_id = external_id
device_repository.save(device)
return external_id
The resolve_external_id function is idempotent on the SNS side, meaning we can safely call it multiple times without creating duplicate registrations. The device_repository.save() call is what creates the DeviceModel entry for a legacy device: after the first send, the record exists in DeviceModel and subsequent reads no longer fall back to FCMDevice. Inactive legacy devices are migrated the moment they receive their next notification, even months later [2].
Phase 1, Expand: Deploy the new DeviceModel, the dual-write repository, and the SnsPushNotificationService (inactive). The platform runs exactly as before.
Phase 2, Migrate: Enable SnsPushNotificationService. All sends now go through SNS; Firebase stays deployed for rollback only. No notifications are missed: active legacy devices are migrated lazily on their first send via resolve_external_id. Concurrently, a bulk migration runs against all legacy devices that have not yet received a notification, creating their DeviceModel entries and populating external_id so that every record is migrated before the next phase.
Phase 3, Contract: Once every device has an external_id, remove the legacy FCM writes from the repository, drop the third-party package, and delete the old implementation, along with the fallback reads from FCMDevice. A database query confirmed no device was left without an external_id before the removal. DeviceModel and the domain model are unchanged.
The application layer is still wired exclusively to Firebase. SnsPushNotificationService, the dual-write repository, and the FCM fallback reads are all deployed, but no traffic flows through SNS yet.

Figure 2. SnsPushNotificationService deployed, no traffic. (Phase 1)
The interface is rewired to use SNS as the primary provider. Firebase stays deployed but receives no traffic. Because the repository keeps FCMDevice up to date, any rollback is a single config change.
A bulk migration also runs at this phase for every legacy device that has not yet received a notification: it calls create_external_id and saves the result via the repository, creating the DeviceModel entry and populating external_id in one step. The lazy strategy covers devices that receive notifications organically, but devices that stay inactive could reach Phase 3 without a DeviceModel entry at all. The bulk migration closes that gap and ensures every such device is represented in DeviceModel with external_id set, the precondition for dropping the old model. If a send and the bulk job hit the same device, both get the same ARN and save() upserts by device_id, so the outcome is the same.

Figure 3. Amazon SNS migration
Firebase is deleted. The graph is clean. Apart from the create_external_id call added at the start, the application layer did not change across the three phases.

Figure 4. FCM service removal
This migration is straightforward because Firebase and Amazon SNS both accept the same input: an FCM device token. The create_external_id method can derive the SNS endpoint ARN from the existing token with a single API call [3], without the device re-registering or the user doing anything.
This is not guaranteed in every migration. Consider moving to a provider that uses a completely different registration flow — one that requires the device to generate a new token through its own SDK, or that assigns identifiers at another stage of the device lifecycle. In that scenario, create_external_id cannot produce the new identifier from the old one. You would need to prompt every active device to re-register before sending to it through the new provider, which is a coordination problem on another scale.
The parallel change pattern still applies in that situation, but the external_id field alone is not enough: you also need a re-registration flow, a way to track which devices have completed it, and a strategy for devices that go inactive before they do. The abstraction boundary still protects the application layer. The migration just has more moving parts underneath it.
Unit tests use a FakePushNotificationService, an in-memory implementation that records calls, runs in milliseconds, and requires no network. Integration tests hit a local AWS emulator, creating real Platform Applications and Endpoints. The separation holds because the interface exists.
No business logic changes: the interface absorbed the migration.
Incremental deployment, since each phase was independently deployable.
Dual-write in one place, because the repository pattern contained the transition entirely.
Third-party model isolation: the owned model could be introduced without changing any consumer.
Fast unit tests throughout, as the fake only needed the new method.
A coherent deprecation path: dropping FCM is a small, isolated change.
In the end, all ~40,000 devices were migrated to SNS: the check before the contract phase found no device without an external_id, and FCM was removed without a coordinated cutover.
The parallel change pattern is not complicated. The discipline it requires is maintaining an abstraction boundary. In a system without that boundary, infrastructure migrations cascade into application changes, then test changes, then risk. The only option is a big-bang cutover because there is no seam to cut along. In a system with that boundary, infrastructure migrations are local. You replace one implementation with another and ship.
That is the real argument for clean architecture. It is neither purity nor ceremony. It is the practical ability to change infrastructure without touching business logic, in phases, safely, in production.
[1] Danilo Sato, "Parallel Change," martinfowler.com, May 13, 2014.
[2] Google, "Best practices for FCM registration management," Firebase Documentation, October 23, 2026.
[3] Amazon Web Services, "Sending mobile push notifications with Amazon SNS" (Amazon SNS Developer Guide), accessed October 27, 2026.











