Repository navigation
Establish the principles of a service-oriented architecture #862
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 23 commits
7f5f024
210e65d
fd7e5fd
f11b267
758a177
e691095
782788d
c606128
fee950f
cb54faf
228a6fd
06a82c6
9937d28
5592b40
64da545
8a537f0
e880d2d
717b124
15feec7
2eb20b6
48be7c5
f0aeb05
07494bb
8bfcfed
1d07899
ecb9f3c
ef09f6e
a7de27c
301a761
eb7b990
6fb9dae
3387674
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,180 @@ | ||
| --- | ||
| adr: "0035" | ||
| status: Proposed | ||
| date: 2026-09-22 | ||
| tags: [server, server-sdk] | ||
| --- | ||
|
|
||
| # 0035 - Service-oriented architecture | ||
|
|
||
| <AdrTable frontMatter={frontMatter}></AdrTable> | ||
|
|
||
| ## Notation | ||
|
|
||
| This ADR uses [RFC 2119](https://www.rfc-editor.org/info/rfc2119/) keywords (`MUST`, `MUST NOT`, | ||
| `SHOULD`, `SHOULD NOT`, `MAY`) deliberately. Anything marked `MUST` or `MUST NOT` is not negotiable | ||
| at team level; a team that needs an exception brings the case to the architecture group. | ||
|
|
||
| ## Context and problem statement | ||
|
withinfocus marked this conversation as resolved.
|
||
|
|
||
| The server is one monolithic application over one monolithic database. Any code path can join across | ||
|
theMickster marked this conversation as resolved.
|
||
| domain boundaries, leaving data with no enforceable owner. The database serves as the integration | ||
| contract, coupling teams directly to one another's tables. Three consequences follow: | ||
|
|
||
| 1. A schema change cannot be reasoned about locally. | ||
| 2. Organization scoping is applied by convention at each call site. | ||
| 3. No team can deploy on its own cadence. | ||
|
|
||
| Every team ships on the monolith's schedule and any regression anywhere blocks everyone. As a | ||
| result, teams may respond by batching work into larger releases, which makes each release riskier to | ||
| review and harder to roll back. Independent deployment is what breaks that cycle: a team that owns | ||
| its store, its service, and its release can make whatever changes they need to make, whenever they | ||
| need to make them, without it becoming a coordinated effort. | ||
|
|
||
| ## Considered options | ||
|
|
||
| - **Status quo:** one monolithic application over one monolithic shared database, with logical | ||
| separation by convention. | ||
| - **Modular monolith:** enforce boundaries in code through module ownership and architecture tests, | ||
| keeping the single store and single deployment. | ||
| - **Event-first services with local read models:** each service owns a store, and cross-boundary | ||
| reads are served from a local projection kept current by an event stream. | ||
| - **Service-oriented architecture:** each service owns a store, cross-boundary access goes through | ||
| the owner's published service client, and events propagate facts. | ||
|
|
||
| ### Status quo | ||
|
|
||
| **Pros** | ||
|
|
||
| - No migration cost, no version skew, no new operational surface. | ||
|
|
||
| **Cons** | ||
|
|
||
| - Does not deliver independent deployment, which is the requirement driving the work. | ||
| - Leaves organization scoping as a convention applied at each call site. | ||
|
|
||
| ### Modular monolith | ||
|
|
||
| **Pros** | ||
|
|
||
| - Real boundary enforcement at compile time, at a fraction of the cost of extraction. | ||
| - Introduces no distributed-systems failure modes. | ||
|
|
||
| **Cons** | ||
|
|
||
| - An architecture test cannot see SQL, so a module boundary does not stop a cross-domain join. | ||
| - Still one deployment, so cadence stays coupled. | ||
|
|
||
| ### Event-first services with local read models | ||
|
|
||
| **Pros** | ||
|
|
||
| - A consumer answers reads without depending on the owner being reachable. | ||
| - No synchronous call path to authenticate, authorize, or operate between services. | ||
|
|
||
| **Cons** | ||
|
|
||
| - A projection is a second implementation of the owner's read logic, including its row-level | ||
| security, and the two can diverge silently. | ||
| - Correctness depends on event delivery, including on the tiers where the message transport is | ||
| weakest. | ||
| - The owner cannot enumerate, reach, or repair copies of its own data, so a representation defect | ||
| cannot be fixed centrally. | ||
|
|
||
| ### Service-oriented architecture | ||
|
|
||
| **Pros** | ||
|
|
||
| - One implementation of each read, owned by the team that owns the rules it enforces. | ||
| - Organization scoping is enforced once, by the owner. | ||
| - Local copies remain available where they are genuinely warranted, as a recorded exception. | ||
|
|
||
| **Cons** | ||
|
withinfocus marked this conversation as resolved.
|
||
|
|
||
| - Introduces a synchronous dependency between services, which must be authenticated, authorized, | ||
| cached, and operated. | ||
| - Adds latency to any read that crosses a boundary and can be particularly harmful if 1 API | ||
| invocation turns into N calls to another service. This, however, is not unlike N+1 database | ||
| queries that can result from a careless for loop and the same strategies used to turn N+1 database | ||
| queries into 2 queries can usually be brought to bear for service-to-service calls, as well. | ||
|
withinfocus marked this conversation as resolved.
|
||
| - Independently deployable services are independently versioned services, which the release pipeline | ||
| has to keep shipping as one coordinated set. | ||
|
|
||
| ## Decision outcome | ||
|
|
||
| Chosen option: **Service-oriented architecture**. | ||
|
|
||
| The rules: | ||
|
|
||
| 1. Service boundaries `MUST` derive from data ownership, not from team structure. | ||
| 2. Every resource `MUST` have exactly one owning service, and that service is the only process that | ||
| reads or writes its data store. | ||
| 3. Services `MUST` be built on the `Bitwarden.Server.Sdk` package. | ||
| 4. Services `MUST` document their APIs in [OpenAPI format](https://www.openapis.org/) and conform to | ||
| **API Standards**. | ||
| 5. Services `MUST NOT` make breaking changes. Changes that _would_ be breaking `MUST` follow the API | ||
| versioning process as outlined by API Standards. | ||
| 6. Services `MUST` provide a **service client** for consumers. | ||
| 7. Service clients `SHOULD` make use of a network cache to mitigate performance issues. | ||
| - Any cache used `MUST` be owned and invalidated by the owning service. | ||
| - Serving results from cache `MUST NOT` bypass authorization the owning service would otherwise | ||
| enforce. | ||
| 8. Services that need to read, write, or validate data owned by another service `SHOULD` do so via | ||
|
theMickster marked this conversation as resolved.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. issue: This feels like a must to me. I don't want to get in a world where we have the option to directly hit APIs to bypass guards built into a client or scatter implementation details to far-flung API call sites.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Mike and I actually talked about this one and softened the language so as to not rule this out.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is there something you foresee that being beneficial for? I think it's likely to produce unspoken contracts and action at a distance effects |
||
| the owner's published service client. | ||
| 9. A service `MAY` hold a local copy of another service's data only with a recorded justification | ||
| (e.g. a measured hot-path volume, a stated availability requirement, etc.). | ||
|
mike-gorman-bitwarden marked this conversation as resolved.
|
||
| - Any service holding a local copy `MUST` enforce the owner's row-level security on that data and | ||
| document the security ramifications of stale reads (due to messaging lag, event processing | ||
| failures, etc.). | ||
| 10. Services `MUST` publish events for every state change using the "transactional outbox" pattern, | ||
|
quexten marked this conversation as resolved.
Outdated
|
||
| regardless of whether there are any known consumers. | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is there value in limiting this scope initially? I may be missing things, but my read of this architecture is that only deletes are needed to propagate cascades. Additionally, this rule binds us to broad-scoped state change statements, rather than tightly typed and defined messages. The latter requires knowledge of consumer needs, but the former creates significant challenges in potentially needing to combine data streams.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The idea here is that if every service broadcasts every state change "as a matter of course", when there is a need to build this integration or that, the team that needs to do so has everything they need. There is never a situation where, in order to make something work, team A has to talk to team B to get them to start doing something. It is a pattern I found to be a very effective way of working. Having said that, I think that we actually do have a consumer for all of these events - and that is our audit log (a.k.a. Events Service) which, of course, wants a permanent record of everything that's happened. In addition to that, are the consumers you mentioned - those that want to make sure they delete related data when parent resources are deleted. So the requirement is for those three reasons:
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Event logs are very far from an audit trail of everything that happens in the Bitwarden database. What's more is there's a layer of authorization that needs to occur on them and it's not obvious to me where that belongs. I also want to point out that
and
My push back here is that this rule will result in generic, all-encompassing change events that are difficult to consume, annoying to maintain, and mostly ignored.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Probably best discussed in person but I am curious to know what makes you think they would be difficult to consume? Presumably, all events share some common top-level "shape" like As for maintenance, I would expect that most transactions go through some common framework that, in addition to persisting the changes, publishes the corresponding event in which case these events all originate from one place, which shouldn't be too hard to maintain. And if every change is something that we need to record for auditing, then, none of these events are ignored. As for authorization, I agree that's something that needs to be worked out. |
||
|
|
||
| ### Positive consequences | ||
|
|
||
| - A resource has one owner, so a schema change is reasoned about locally. | ||
| - A single owning process makes organization scoping enforceable in one place. | ||
| - Teams deploy on their own cadence against a published contract. | ||
| - Consumers write the same code on every deployment tier; the service client resolves how a call is | ||
| made. | ||
| - Audit and future integrations read one event stream that already exists. | ||
| - Local copies stay available where warranted, with the justification and the staleness consequences | ||
| recorded where the copy is introduced. | ||
|
|
||
| ### Negative consequences | ||
|
|
||
| - **Versioning.** Independently deployable services will likely be versioned independently as well, | ||
| and enhancements and bug fixes will land service by service, each producing a new version of that | ||
| service. Rule 5 ensures a new version never introduces a breaking change, so a customer may at any | ||
| time run an "upgrade everything" script and take the latest of every service — which is exactly | ||
| what self-host installs. Our obligation is to just make sure "the latest version of everything" | ||
| always works. | ||
|
mike-gorman-bitwarden marked this conversation as resolved.
Outdated
|
||
| - **A synchronous dependency now exists where none did.** It has to be authenticated, authorized, | ||
|
theMickster marked this conversation as resolved.
|
||
| observed, and operated. A dependency's unavailability becomes a caller's failure mode. | ||
| - **Service-to-service authentication has to be built for cloud.** The existing internal grant has | ||
| only ever been registered for self-hosted deployments. | ||
| - **Every extracted service is another process on the smallest tier.** Bitwarden Lite already runs | ||
| nine processes on one box against an operator-supplied database, so service count is priced there | ||
| first. | ||
| - **Row-level security in the data layer is not yet portable.** The current implementation composes | ||
| T-SQL and has no Entity Framework path, so the enforcement this ADR relies on is available on SQL | ||
| Server only until that gap is closed. | ||
| - **A cache cannot be assumed to exist.** Redis and Cosmos are both exposed through configuration | ||
| and some self-host operators do configure one, but neither is provisioned by default and Bitwarden | ||
| Lite ships no cache service at all. Rule 7's caching is therefore an optimization a service may | ||
| find available, never a mechanism it can depend on being there. | ||
|
|
||
| ### Plan | ||
|
|
||
| - Publish **API Standards**. Rules 4 and 5 reference it normatively and it does not exist yet on | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ℹ️ This will be edited / amended once in place, or its development will be clearly indicated. |
||
| this site. | ||
|
mike-gorman-bitwarden marked this conversation as resolved.
Outdated
|
||
| - Publish a client strategy page covering how a service client is generated, wrapped, versioned, and | ||
| cached. | ||
| - Publish a service identity and context page covering service-to-service authentication, | ||
| authorization by scope, and context propagation, and build the cloud path it describes. | ||
| - Provide an Entity Framework path for organization scoping, so rule 9 holds on all supported | ||
| database providers. | ||
| - Provide a transactional outbox and a broker-free event transport, so rule 10 holds on deployments | ||
| that ship no broker. | ||
| - Decide the shared cache posture for full self-host and Bitwarden Lite. The cache implementation is | ||
| settled by [ADR-0028](./0028-adopt-fusion-cache.md). | ||
| - Apply the standard to the next service extraction as the reference implementation. | ||
Uh oh!
There was an error while loading. Please reload this page.