<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Odinaka Robert Nnamani</title>
    <description>The latest articles on DEV Community by Odinaka Robert Nnamani (@robertpaschal).</description>
    <link>https://dev.to/robertpaschal</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F2167197%2Fecea0dc6-e64a-4d17-86da-0ec53a674663.jpeg</url>
      <title>DEV Community: Odinaka Robert Nnamani</title>
      <link>https://dev.to/robertpaschal</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/robertpaschal"/>
    <language>en</language>
    <item>
      <title>Per-tenant sender reputation on one SES account</title>
      <dc:creator>Odinaka Robert Nnamani</dc:creator>
      <pubDate>Thu, 29 Oct 2026 12:25:48 +0000</pubDate>
      <link>https://dev.to/robertpaschal/per-tenant-sender-reputation-on-one-ses-account-3594</link>
      <guid>https://dev.to/robertpaschal/per-tenant-sender-reputation-on-one-ses-account-3594</guid>
      <description>&lt;p&gt;If you send email on behalf of other businesses, you are holding a shared fuse. One customer imports a stale list, the bounces pile up, and the account that pauses isn't theirs. It's yours, and with it every other customer who did nothing wrong.&lt;/p&gt;

&lt;p&gt;That's the problem I kept circling while building Rootmail, a business email platform where each workspace and sub-tenant sends from its own verified domain with its own DKIM signing key. Separate domains and keys are the visible half of isolation. The half that actually decides whether one customer can hurt another is reputation. For a long time on SES, reputation lived at the account level.&lt;/p&gt;

&lt;p&gt;It doesn't have to anymore. SES now has tenants, and this post is about the one mechanism I'd want a stranger to walk away with: give every customer a tenant, make every send name it, and let the pause land on the tenant instead of the account.&lt;/p&gt;

&lt;h2&gt;
  
  
  What an SES tenant actually is
&lt;/h2&gt;

&lt;p&gt;A tenant is a logical container inside your SES account. You associate resources with it (verified identities, configuration sets, templates), and SES tracks reputation, bounce and complaint rates plus feedback-loop and third-party signals, per tenant.&lt;/p&gt;

&lt;p&gt;Two details make it real rather than cosmetic.&lt;/p&gt;

&lt;p&gt;First, SES checks the association on every send. If the identity, configuration set or template in the request isn't associated with the tenant you named, the send fails. That's a guardrail you get for free: customer A's code path can't quietly sign mail as customer B's domain.&lt;/p&gt;

&lt;p&gt;Second, enforcement is scoped. Each tenant has a reputation policy. &lt;strong&gt;Standard&lt;/strong&gt; (the default) pauses the tenant on high-severity findings, &lt;strong&gt;Strict&lt;/strong&gt; pauses on any finding, and &lt;strong&gt;None&lt;/strong&gt; records findings but never pauses automatically. When a tenant is paused, sends that name it fail. Every other tenant keeps sending.&lt;/p&gt;

&lt;h2&gt;
  
  
  Setting one up, in TypeScript
&lt;/h2&gt;

&lt;p&gt;My stack is TypeScript, Postgres, Redis and SES, so this is how I'd wire it with &lt;code&gt;@aws-sdk/client-sesv2&lt;/code&gt;. Onboarding a customer would look like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;SESv2Client&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;CreateTenantCommand&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;CreateTenantResourceAssociationCommand&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@aws-sdk/client-sesv2&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ses&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;SESv2Client&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;region&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;us-east-1&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ACCOUNT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;AWS_ACCOUNT_ID&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;REGION&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;us-east-1&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;provisionTenant&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tenantName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;domain&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;configSet&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;ses&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;CreateTenantCommand&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;TenantName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;tenantName&lt;/span&gt; &lt;span class="p"&gt;}));&lt;/span&gt;

  &lt;span class="c1"&gt;// A tenant needs at least one verified identity and one configuration set before it can send.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;arns&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="s2"&gt;`arn:aws:ses:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;REGION&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;ACCOUNT&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:identity/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;domain&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s2"&gt;`arn:aws:ses:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;REGION&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;ACCOUNT&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:configuration-set/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;configSet&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;];&lt;/span&gt;

  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ResourceArn&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;arns&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;ses&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;CreateTenantResourceAssociationCommand&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;TenantName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;tenantName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ResourceArn&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The identity has to be verified first (the domain, its DKIM records, the usual). Association doesn't verify anything. It only grants the tenant permission to use what you've already verified.&lt;/p&gt;

&lt;p&gt;I'd give each customer their own configuration set rather than sharing one. Sharing is allowed, but a dedicated set means event destinations, and anything you read off them, already belong to one tenant without extra tagging.&lt;/p&gt;

&lt;h2&gt;
  
  
  Every send names its tenant
&lt;/h2&gt;

&lt;p&gt;This is the part that's easy to forget in a codebase that predates tenants. The tenant is a field on the request, not something SES infers from the From address:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;SendEmailCommand&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@aws-sdk/client-sesv2&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;OutboundMessage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;tenantName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;configSet&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;from&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;subject&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;sendForTenant&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;OutboundMessage&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;ses&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;SendEmailCommand&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;TenantName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tenantName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;ConfigurationSetName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;configSet&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;FromEmailAddress&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;Destination&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;ToAddresses&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;to&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="na"&gt;Content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;Simple&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="na"&gt;Subject&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;Data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subject&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
          &lt;span class="na"&gt;Body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;Text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;Data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you send over SMTP instead, the same thing travels as an &lt;code&gt;X-SES-TENANT&lt;/code&gt; header.&lt;/p&gt;

&lt;p&gt;When sending for a tenant you must pass a configuration set associated with it, or use an identity whose default configuration set is associated with it. If you leave both out, you'll find out at send time, not at deploy time. I'd rather have the type system force it, which is why &lt;code&gt;configSet&lt;/code&gt; isn't optional above.&lt;/p&gt;

&lt;h2&gt;
  
  
  Listening for the pause
&lt;/h2&gt;

&lt;p&gt;A tenant that SES pauses is a tenant your app should stop queuing for. Otherwise your workers spend the next hour retrying sends that will all fail. SES publishes tenant events to the EventBridge default bus, so you can route them to an SQS queue or a Lambda.&lt;/p&gt;

&lt;p&gt;There's a subtlety in the event shape worth knowing before you write the handler. The &lt;code&gt;Sending Status Disabled&lt;/code&gt; detail-type fires for both a pause and a reinstatement. The truth is in &lt;code&gt;detail.data.record.status&lt;/code&gt;, which is &lt;code&gt;DISABLED&lt;/code&gt; or &lt;code&gt;REINSTATED&lt;/code&gt;. And that status applies only to the origin named in &lt;code&gt;detail.data.origin&lt;/code&gt;: &lt;code&gt;AWS_SES&lt;/code&gt; if SES changed it, &lt;code&gt;CUSTOMER_MANAGED&lt;/code&gt; if you did. Either origin being disabled stops the tenant.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;TenantStatusEvent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;detail-type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Sending Status Enabled&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Sending Status Disabled&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;resources&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[];&lt;/span&gt; &lt;span class="c1"&gt;// tenant ARN&lt;/span&gt;
  &lt;span class="nl"&gt;detail&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;origin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;AWS_SES&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;CUSTOMER_MANAGED&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="c1"&gt;// ENABLED arrives with "Sending Status Enabled"; this handler only needs to know whether it is DISABLED.&lt;/span&gt;
      &lt;span class="nl"&gt;record&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ENABLED&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;DISABLED&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;REINSTATED&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;cause&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;./db&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// a pg Pool&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;onTenantStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;evt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;TenantStatusEvent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tenantArn&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;evt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;resources&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;origin&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;record&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;evt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;detail&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="c1"&gt;// Don't branch on detail-type alone: "Disabled" also covers REINSTATED.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;blocked&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;record&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;DISABLED&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s2"&gt;`insert into tenant_send_gate (tenant_arn, origin, blocked, cause, updated_at)
     values ($1, $2, $3, $4, now())
     on conflict (tenant_arn, origin)
     do update set blocked = $3, cause = $4, updated_at = now()`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;tenantArn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;origin&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;blocked&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;record&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cause&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Keeping one row per origin mirrors how SES itself reasons about it. A tenant can send only when no origin is blocked. If you'd rather not reconstruct that yourself, &lt;code&gt;GetReputationEntity&lt;/code&gt; returns the combined value as &lt;code&gt;SendingStatusAggregate&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;In this wiring, the queue worker checks that gate before it calls SES, right next to the suppression check. In Rootmail, every send already runs a suppression check first, and a provider event goes into the message's append-only lifecycle log as evidence of what the system observed, not as proof that someone read anything. A tenant pause is one more piece of that evidence.&lt;/p&gt;

&lt;h2&gt;
  
  
  Suppression is shared unless you say otherwise
&lt;/h2&gt;

&lt;p&gt;This one surprised me. By default, every tenant shares the account-level suppression list. If a recipient hard-bounces on customer A's mail, that address is suppressed for customer B too, even though B may have a perfectly good relationship with them.&lt;/p&gt;

&lt;p&gt;SES supports tenant-level suppression lists. You set the scope to &lt;code&gt;TENANT&lt;/code&gt; and choose whether bounces, complaints or both are tracked. Then one tenant's bounce stays that tenant's problem. Whether that's what you want depends on your product, but it should be a decision, not a default you discover later.&lt;/p&gt;

&lt;h2&gt;
  
  
  What isolation doesn't buy you
&lt;/h2&gt;

&lt;p&gt;Here are the edges, all from the SES docs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The account still carries the sum.&lt;/strong&gt; Tenant tracking isolates reputation, but AWS says plainly that combined tenant activity still affects your overall account standing. A badly behaved tenant can still put the account at risk. Tenants contain the blast radius; they don't remove your responsibility to watch it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Findings need volume.&lt;/strong&gt; Some reputation findings only trigger after a minimum representative volume, and metrics are calculated over rolling windows from about 24 hours to 7 days. A tenant that sends a little won't show a finding quickly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tenants are regional and flat.&lt;/strong&gt; They don't replicate across regions, they don't nest, and they can't span accounts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;There's a per-tenant charge.&lt;/strong&gt; It's on the SES pricing page. Factor it in before mapping every end user to a tenant.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And the edge I live with every day: none of this matters to outbound mail until your account leaves the SES sandbox. In the sandbox you can send only to verified addresses and the mailbox simulator. Rootmail's SES account is still in the sandbox. My production access request was denied, and I'm reapplying with a clean sending history. So the beta is honest about that: testers send only to their own AWS-confirmed inboxes and the simulator, capped at 12 sends a day per org. The tenant wiring works the same either way; what's missing is real recipients.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this leaves you
&lt;/h2&gt;

&lt;p&gt;If you're building something multi-tenant on SES (a booking tool, a CRM, a clinic system, an agency sending for clients), the mechanism is small: one tenant per customer, a configuration set associated with it, &lt;code&gt;TenantName&lt;/code&gt; on every send, an EventBridge rule that closes your own gate when SES closes theirs, and a deliberate choice about suppression scope.&lt;/p&gt;

&lt;p&gt;Rootmail is built around that problem: workspaces and sub-tenants with their own verified domains, signing keys and isolated reputation, behind one API. The closed beta has 8 seats, with the sandbox limits above stated plainly. The code in this post is how I'd wire the SES side, not a copy of Rootmail's. If you send for other people and want to try it, testers confirm Amazon's verification email within 24 hours, and places are open at &lt;a href="https://rootmail.io/beta?ref=devto" rel="noopener noreferrer"&gt;https://rootmail.io/beta?ref=devto&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>aws</category>
      <category>email</category>
      <category>saas</category>
      <category>typescript</category>
    </item>
    <item>
      <title>Counter-Intuitive Call — sub-tenancy is not an enterprise feature</title>
      <dc:creator>Odinaka Robert Nnamani</dc:creator>
      <pubDate>Thu, 29 Oct 2026 12:00:46 +0000</pubDate>
      <link>https://dev.to/robertpaschal/counter-intuitive-call-sub-tenancy-is-not-an-enterprise-feature-l06</link>
      <guid>https://dev.to/robertpaschal/counter-intuitive-call-sub-tenancy-is-not-an-enterprise-feature-l06</guid>
      <description>&lt;p&gt;Every email API will sell you sub-tenancy. Mailgun calls it subaccounts — $90/month, Scale plan minimum.&lt;/p&gt;

&lt;p&gt;What that price actually tells you: multi-tenancy was built on top, not into the data model. Things bolted on afterwards are expensive to run, so they get sold to the customers who can absorb it.&lt;/p&gt;

&lt;p&gt;But if you send email on behalf of your customers, you inherit their behaviour. One tenant imports a dirty list, your whole domain eats the complaint, and every other customer's password resets start landing in spam.&lt;/p&gt;

&lt;p&gt;That isn't an enterprise problem. It's your first problem — day one, ten customers, before you have any revenue to justify a $90 line item.&lt;/p&gt;

&lt;p&gt;So the tier structure gets it exactly backwards: the people who most need a tenant boundary are the ones priced out of having one until after it's already gone wrong.&lt;/p&gt;

&lt;p&gt;I built Rootmail with the boundary in the schema. Not as a plan. Closed beta at &lt;a href="https://rootmail.io/beta" rel="noopener noreferrer"&gt;https://rootmail.io/beta&lt;/a&gt; — 8 seats, free, for people who'll tell me where it breaks.&lt;/p&gt;

&lt;p&gt;If you run a platform that sends as your customers — when did sub-tenancy stop being optional for you?&lt;/p&gt;

&lt;p&gt;Follow along if you build platforms that send as their customers. Tomorrow: "A silent failure is not best-effort," the first entry in a weekly commit log.&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>saas</category>
    </item>
    <item>
      <title>7 backend lessons I learned the hard way</title>
      <dc:creator>Odinaka Robert Nnamani</dc:creator>
      <pubDate>Wed, 28 Oct 2026 12:00:17 +0000</pubDate>
      <link>https://dev.to/robertpaschal/7-backend-lessons-i-learned-the-hard-way-2db2</link>
      <guid>https://dev.to/robertpaschal/7-backend-lessons-i-learned-the-hard-way-2db2</guid>
      <description>&lt;p&gt;I'm building my first mobile app with microservices. Here are 7 backend lessons I learned the hard way.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Own your data, or nobody does.&lt;br&gt;
Each service owns its own data. No shared database that every service reads and writes directly. The moment two services share a table, a change in one breaks the other, and nobody can say who owns the truth anymore.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Make retries safe.&lt;br&gt;
Networks fail and clients retry. If the same request twice creates two charges, two rows, or two emails, the retry becomes the bug. The rule I keep: the same request, safely repeated, must produce the same result, with no duplicate side effect.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Put the rule in the database, not just the app.&lt;br&gt;
Validation in the app is good. A constraint in the database is the one every future app, script, and service has to respect. Apps get rewritten. The database is the last writer standing.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Auth lives in one place.&lt;br&gt;
One auth service, verified at the boundary. Not a login check copied into every handler, drifting a little each time it is copied. When auth changes, it should change in one place.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Version your contracts.&lt;br&gt;
Services talk through APIs, and APIs are promises. Change a field, rename a response, or drop an endpoint without warning, and you break callers who did nothing wrong. Version it, keep the old one working, and give callers a path forward.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Observe before you scale.&lt;br&gt;
Shipping is not the same as knowing. CI/CD that deploys small changes, metrics that show what is actually happening, and logs you can search will tell you more than a bigger server will. If you cannot see it, you cannot fix it.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Ship the smallest backend that works, then harden it.&lt;br&gt;
A small backend you can change beats a clever one you are afraid to touch. Get the core working, watch how it behaves, then add the complexity it has actually earned.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;None of these is clever. Together they are the difference between a backend you can keep building on and one that fights you every time you touch it.&lt;/p&gt;

&lt;p&gt;I write about backend systems that look small until they aren't. Follow along if that's your world; the longer pieces live on my Substack.&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>backend</category>
      <category>microservices</category>
    </item>
    <item>
      <title>Debugging a distributed mystery</title>
      <dc:creator>Odinaka Robert Nnamani</dc:creator>
      <pubDate>Tue, 27 Oct 2026 12:02:04 +0000</pubDate>
      <link>https://dev.to/robertpaschal/debugging-a-distributed-mystery-1jj0</link>
      <guid>https://dev.to/robertpaschal/debugging-a-distributed-mystery-1jj0</guid>
      <description>&lt;p&gt;In a monolith, a bug lives in one place. You open the logs, you find it.&lt;/p&gt;

&lt;p&gt;Microservices change the problem. A single user action can cross 5+ services — and the failure can sit in any one of them, or in the gap between two of them.&lt;/p&gt;

&lt;p&gt;Take a signup:&lt;/p&gt;

&lt;p&gt;Auth service → User service → Notification service → Email provider&lt;/p&gt;

&lt;p&gt;Say it fails halfway. The user exists, but the welcome never arrives. Or the account never gets created, but the notification already went out. Where do you look?&lt;/p&gt;

&lt;p&gt;Without centralised logging and tracing, you open four dashboards, four log files, four different formats — and you guess. Each team can honestly say "it worked on my side," because each side only saw its own hop.&lt;/p&gt;

&lt;p&gt;The fix is not more logs. It is one thread through all of them:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;One request ID, carried across every hop, from the first request to the last.&lt;/li&gt;
&lt;li&gt;Centralised logs, searchable by that ID — so one search shows the whole journey, not one service's slice of it.&lt;/li&gt;
&lt;li&gt;Tracing that shows the order and timing of each hop — so you can see where the chain broke, not just that it broke.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;With that, debugging stops being a mystery. You follow the request the way the user experienced it: Auth, then User, then Notification, then the provider — and the hop where the thread goes quiet is where you look.&lt;/p&gt;

&lt;p&gt;The rule I keep: no service starts work without the request ID. A log line you cannot tie back to a request is a dead end, and a system full of dead ends is a system you cannot debug.&lt;/p&gt;

&lt;p&gt;Microservices do not make bugs harder to write. They make evidence harder to follow. Centralise the evidence, and the mystery goes away.&lt;/p&gt;

&lt;p&gt;Next in this series: 7 backend lessons I learned the hard way. I write about backend systems that look small until they aren't — follow along if that's your world; the longer pieces live on my Substack.&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>debugging</category>
      <category>microservices</category>
      <category>monitoring</category>
    </item>
    <item>
      <title>All DB access lives in one place</title>
      <dc:creator>Odinaka Robert Nnamani</dc:creator>
      <pubDate>Mon, 26 Oct 2026 12:40:54 +0000</pubDate>
      <link>https://dev.to/robertpaschal/all-db-access-lives-in-one-place-4dj3</link>
      <guid>https://dev.to/robertpaschal/all-db-access-lives-in-one-place-4dj3</guid>
      <description>&lt;p&gt;One mistake I see in backend codebases:&lt;/p&gt;

&lt;p&gt;ORM queries scattered everywhere.&lt;/p&gt;

&lt;p&gt;Controllers query the DB. Services query the DB. Helpers query the DB. The little cron job queries the DB. Change one column and you're grep-ing the whole repo, praying you found every query that touches it.&lt;/p&gt;

&lt;p&gt;Soon your database logic is impossible to track — and nobody can tell you where a value actually gets read or written.&lt;/p&gt;

&lt;p&gt;A better pattern: the repository layer.&lt;/p&gt;

&lt;p&gt;All DB access lives in one place. Controllers ask the repository; they never touch the ORM. Schema knowledge — joins, filters, transaction boundaries — lives in one module per entity. The rest of the codebase just calls methods with names that say what they mean: findActiveUsers(), not db.select().where(...).&lt;/p&gt;

&lt;p&gt;Three things this buys you:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Cleaner code — business logic stops drowning in query syntax.&lt;/li&gt;
&lt;li&gt;Safer schema changes — rename a column in one file, not seventeen.&lt;/li&gt;
&lt;li&gt;A clean test seam — the boundary you need to mock is obvious.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The rule I keep: no raw ORM calls outside the repository. No exceptions for "it's just a small helper." Break the rule once and it's no longer a rule.&lt;/p&gt;

&lt;p&gt;All DB access lives in one place. That's the whole point.&lt;/p&gt;

&lt;p&gt;Next in this series: debugging a distributed mystery. I write about backend systems that look small until they aren't — follow along if that's your world; the longer pieces live on my Substack.&lt;/p&gt;

</description>
      <category>backend</category>
      <category>database</category>
      <category>architecture</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Code evolves quickly, databases remember everything</title>
      <dc:creator>Odinaka Robert Nnamani</dc:creator>
      <pubDate>Sun, 25 Oct 2026 12:01:19 +0000</pubDate>
      <link>https://dev.to/robertpaschal/code-evolves-quickly-databases-remember-everything-3g4e</link>
      <guid>https://dev.to/robertpaschal/code-evolves-quickly-databases-remember-everything-3g4e</guid>
      <description>&lt;p&gt;A common backend mistake: treating database schemas like temporary code.&lt;/p&gt;

&lt;p&gt;Code deploys in minutes. You ship it, watch it, roll it back if it's wrong. A schema doesn't work like that. The moment production data exists, the schema becomes the memory of the system — and memory doesn't roll back.&lt;/p&gt;

&lt;p&gt;Every shortcut compounds:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The column you added as nullable "for now" still has three meanings two years later&lt;/li&gt;
&lt;li&gt;The status value you retired still sits in a million old rows&lt;/li&gt;
&lt;li&gt;The constraint you skipped in the app gets skipped by the second service too&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So schema design has to consider what code design can postpone: relationships, constraints, future queries, data integrity. Put the constraint in the database, not just the validation layer — applications change, get rewritten, multiply. The database is the last writer standing, and the only one that sees every row.&lt;/p&gt;

&lt;p&gt;And migrations on live tables are production events, not chores: expand, backfill, dual-write, contract — in that order, over days, not in one deploy.&lt;/p&gt;

&lt;p&gt;Code evolves quickly. Databases remember everything. Design like it.&lt;/p&gt;

&lt;p&gt;Next in this series: all DB access lives in one place. I write about backend systems that look small until they aren't — follow along if that's your world; the longer pieces live on my Substack.&lt;/p&gt;

</description>
      <category>database</category>
      <category>postgres</category>
      <category>architecture</category>
      <category>backend</category>
    </item>
    <item>
      <title>One action, multiple reactions</title>
      <dc:creator>Odinaka Robert Nnamani</dc:creator>
      <pubDate>Sat, 24 Oct 2026 12:01:43 +0000</pubDate>
      <link>https://dev.to/robertpaschal/one-action-multiple-reactions-4one</link>
      <guid>https://dev.to/robertpaschal/one-action-multiple-reactions-4one</guid>
      <description>&lt;p&gt;Building event-driven systems changed how I think about software.&lt;/p&gt;

&lt;p&gt;Services shouldn't call each other constantly. They should react to events.&lt;/p&gt;

&lt;p&gt;Example:&lt;/p&gt;

&lt;p&gt;User signs up → Auth emits an event&lt;br&gt;
→ Notification service sends the welcome email&lt;br&gt;
→ Analytics service records the signup&lt;br&gt;
→ User service continues onboarding&lt;/p&gt;

&lt;p&gt;One action. Multiple reactions.&lt;/p&gt;

&lt;p&gt;The biggest shift is mental: you stop designing request chains and start designing system reactions. The signup service doesn't need to know who cares that a user signed up — it publishes the fact, and the system reacts.&lt;/p&gt;

&lt;p&gt;The honest cost: debugging gets harder. A request chain fails in one place, with a stack trace. An event fans out, and the failure can hide inside any of the reactions — so observability stops being optional, and patterns like the transactional outbox (write the event in the same transaction as the state change, so the two can never disagree) stop being nice-to-haves.&lt;/p&gt;

&lt;p&gt;I run an email platform built this way: a send is one action, and delivery, bounces, complaints and suppression are all reactions to provider events. It's the only shape I've found that scales without the services strangling each other.&lt;/p&gt;

&lt;p&gt;Next in this series: code evolves quickly, databases remember everything. I write about backend systems that look small until they aren't — follow along if that's your world; the longer pieces live on my Substack.&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>backend</category>
      <category>webdev</category>
      <category>programming</category>
    </item>
    <item>
      <title>Auth systems aren't features, they're mini platforms</title>
      <dc:creator>Odinaka Robert Nnamani</dc:creator>
      <pubDate>Fri, 23 Oct 2026 12:02:24 +0000</pubDate>
      <link>https://dev.to/robertpaschal/auth-systems-arent-features-theyre-mini-platforms-2ek4</link>
      <guid>https://dev.to/robertpaschal/auth-systems-arent-features-theyre-mini-platforms-2ek4</guid>
      <description>&lt;p&gt;Authentication systems are more complex than most apps expect.&lt;/p&gt;

&lt;p&gt;Signup alone involves:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Email verification&lt;/li&gt;
&lt;li&gt;OTP storage&lt;/li&gt;
&lt;li&gt;Redis for temporary tokens&lt;/li&gt;
&lt;li&gt;Event emission for notifications&lt;/li&gt;
&lt;li&gt;Audit logging&lt;/li&gt;
&lt;li&gt;User service onboarding&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And that's just creating an account.&lt;/p&gt;

&lt;p&gt;Auth systems aren't features. They're mini platforms.&lt;/p&gt;

&lt;p&gt;Login is the smallest part of one. Underneath it sits identity proofing, session lifecycle, token storage, rate limiting on credential attempts, and an audit trail you can actually reconstruct an incident from — user agent, IP, every credential attempt, logged in a shape fraud detection can use later.&lt;/p&gt;

&lt;p&gt;Most teams scope auth as a ticket. Then they rebuild it three times: once when the first fraud wave hits, once when a customer asks for the audit log, and once when sessions have to work across services.&lt;/p&gt;

&lt;p&gt;I write about the backend systems that look small until they aren't — email infra, retries, identity. Follow along if that's your world; the longer pieces live on my Substack (link in bio).&lt;/p&gt;

</description>
      <category>backend</category>
      <category>webdev</category>
      <category>security</category>
      <category>architecture</category>
    </item>
    <item>
      <title>One tenant's bad list is spending your SES reputation</title>
      <dc:creator>Odinaka Robert Nnamani</dc:creator>
      <pubDate>Thu, 22 Oct 2026 12:00:08 +0000</pubDate>
      <link>https://dev.to/robertpaschal/one-tenants-bad-list-is-spending-your-ses-reputation-480j</link>
      <guid>https://dev.to/robertpaschal/one-tenants-bad-list-is-spending-your-ses-reputation-480j</guid>
      <description>&lt;h1&gt;
  
  
  One tenant's bad list is spending your SES reputation
&lt;/h1&gt;

&lt;p&gt;If you run a multi-tenant SaaS on Amazon SES, you probably have one SES account, one or two verified identities, and every tenant's email flowing through them.&lt;/p&gt;

&lt;p&gt;That means you also have exactly one sender reputation. And every tenant you onboard gets to spend it.&lt;/p&gt;

&lt;p&gt;Here's the scenario that should worry you. A tenant imports a purchased list — or an old one, scraped from a CRM export nobody cleaned. They send a campaign. Hard bounces spike. A few recipients mark it as spam. SES watches two numbers across your &lt;em&gt;whole account&lt;/em&gt;: bounce rate and complaint rate. Drift toward a 5% bounce rate or a 0.1% complaint rate and your account goes under review. Push further and SES pauses your sending.&lt;/p&gt;

&lt;p&gt;Not that tenant's sending. &lt;em&gt;Your&lt;/em&gt; sending. Every tenant. The good ones — the ones with clean lists and double opt-ins — wake up to the same dead deliverability as the one who caused it. Their password resets land in spam. Their invoices never arrive. And your dashboard, if it only tracks account-level numbers, tells you something is wrong long after all your tenants already know.&lt;/p&gt;

&lt;p&gt;This isn't a monitoring problem you can alert your way out of. It's an architecture problem. Reputation on SES is pooled by default, so isolation has to be something you build — in three separate places.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Give each tenant its own sending identity
&lt;/h2&gt;

&lt;p&gt;Reputation signals attach to identities: domains, MAIL FROM domains, IP pools. If all your tenants send from one domain on the shared IP pool, there is nothing for those signals to segment &lt;em&gt;by&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;What I build instead:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A sending subdomain per tenant&lt;/strong&gt; — &lt;code&gt;mail.tenant.yourdomain.com&lt;/code&gt;, or the tenant's own domain once they're serious — with its own SPF/DKIM alignment. A tenant who destroys their subdomain's standing has hurt &lt;em&gt;their&lt;/em&gt; subdomain, not your root domain and not the tenant next door.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A configuration set per tenant.&lt;/strong&gt; This is the unglamorous piece everything else hangs off. Configuration sets are how you tag a tenant's sends so every bounce, complaint, and delivery event comes back labelled with which tenant produced it. One shared configuration set means unattributed events, and unattributed events mean you're back to guessing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dedicated IP pools only for tenants who earn them.&lt;/strong&gt; SES lets you assign configuration sets to dedicated IP pools. A tenant with steady, real volume can graduate to their own pool, fully out of the shared blast radius. Tenants without consistent volume stay on shared IPs — a cold dedicated IP with sporadic sends is its own deliverability problem.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  2. Keep a per-tenant ledger, not an account-level blob
&lt;/h2&gt;

&lt;p&gt;SES hands you an account-level suppression list and a stream of events. Most teams wire the events to an SNS topic, glance at a dashboard, and move on.&lt;/p&gt;

&lt;p&gt;Split the stream by tenant (that's what the per-tenant configuration sets are for) and persist it as a ledger: every send, bounce, complaint, and suppression, recorded per tenant, append-only. Two things fall out of this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Suppression becomes per-tenant too.&lt;/strong&gt; An address that hard-bounced for Tenant A gets suppressed for Tenant A. On a shared account-level list, one tenant's typo'd import can silently block addresses your other tenants legitimately need to reach.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You get a reputation balance per tenant.&lt;/strong&gt; Bounce rate, complaint rate, suppression growth — per tenant, over time. Not "the account looks bad this week," but "this tenant has been decaying for six days." That's the difference between an incident and a conversation.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  3. Gate sending on per-tenant outcomes
&lt;/h2&gt;

&lt;p&gt;Isolation without enforcement is just better observability of your own outage. The point of measuring per tenant is that you can &lt;em&gt;act&lt;/em&gt; per tenant.&lt;/p&gt;

&lt;p&gt;The pattern I use — and the one I keep coming back to when I talk to other builders about this — is an outcome gate per tenant:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Thresholds per tenant.&lt;/strong&gt; When a tenant's bounce or complaint rate crosses your line, the gate pauses &lt;em&gt;that tenant's&lt;/em&gt; sending automatically. The account stays healthy, the other tenants never notice, and the paused tenant gets a specific reason instead of your whole platform getting an SES review.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A ramp for new tenants.&lt;/strong&gt; Nobody sends at full volume on day one. New tenants start on a paced schedule with a daily ceiling that rises as their outcome history proves out. A bad list then burns a small, contained allowance — not your account.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;List hygiene before the first campaign, not after.&lt;/strong&gt; The gate is the last line of defence. Checking an import for the obvious rot before it's allowed to send is the first one.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I sketched this gate design out with another builder in a GitHub thread recently — he'd been running deliverability as one global dashboard and treating every incident as platform-wide. He folded the per-tenant gate into his product's roadmap the same week and told me it filled a real gap in his design. It usually does. Most multi-tenant stacks built on SES have exactly this hole; they just haven't had the tenant yet who finds it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to check in your stack today
&lt;/h2&gt;

&lt;p&gt;You don't need a rebuild to find out how exposed you are. Four questions:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;If one tenant spiked your bounce rate tonight, could you name them from your own data tomorrow morning — or only that "the account" had a bad day?&lt;/li&gt;
&lt;li&gt;Do your bounce and complaint events arrive tagged by tenant, or in one undifferentiated stream?&lt;/li&gt;
&lt;li&gt;Is suppression enforced per tenant, or is one tenant's bad import blocking addresses for everyone?&lt;/li&gt;
&lt;li&gt;What, mechanically, stops a tenant from sending their worst list at full volume right now? If the answer is "we'd notice," you don't have a control — you have a hope.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you can't answer those cleanly, your tenants are sharing more than an SES account. They're sharing a reputation, a suppression list, and an outage waiting for its trigger.&lt;/p&gt;

&lt;p&gt;I'm building these three pieces — per-tenant identities, per-tenant ledgers, per-tenant outcome gates — into rootmail, an email infrastructure layer on SES, and the beta is open if you want to see the approach in practice: &lt;a href="https://rootmail.io/beta" rel="noopener noreferrer"&gt;https://rootmail.io/beta&lt;/a&gt;&lt;/p&gt;

</description>
      <category>aws</category>
      <category>email</category>
      <category>saas</category>
      <category>backend</category>
    </item>
    <item>
      <title>Your microservices aren’t decoupled</title>
      <dc:creator>Odinaka Robert Nnamani</dc:creator>
      <pubDate>Wed, 21 Oct 2026 12:42:43 +0000</pubDate>
      <link>https://dev.to/robertpaschal/your-microservices-arent-decoupled-4ehc</link>
      <guid>https://dev.to/robertpaschal/your-microservices-arent-decoupled-4ehc</guid>
      <description>&lt;p&gt;Splitting a monolith into services gives you the vocabulary for free: independent, decoupled, autonomous. The architecture has to earn those words — and most don’t.&lt;/p&gt;

&lt;p&gt;There’s a fast way to tell whether yours did. Don’t look at the deployment diagram. Look at where the contracts live.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup almost everyone starts with
&lt;/h2&gt;

&lt;p&gt;In a gRPC system, the contracts are the .proto files: the message shapes and service definitions every service agrees to speak. They have to live somewhere, and somewhere usually means "inside whichever service was built first."&lt;/p&gt;

&lt;p&gt;So the orders service owns the protos. The users service needs them, so it imports them straight from the orders repo. Payments needs the same messages, and rather than set up the import, someone copy-pastes the files and moves on.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;orders-service/          ← .proto files live here
├── users-service        imports them from orders
└── payments-service     copy-pasted them
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It works. It ships. And it quietly wires every service to every other service.&lt;/p&gt;

&lt;h2&gt;
  
  
  The hidden coupling
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Coordinated deployments.&lt;/strong&gt; The moment a contract lives inside one service, changing it stops being one team’s change. Rename a field, add a required field, and every service importing that proto has to move with you. Your "independent" services now deploy in lockstep — the exact thing microservices were supposed to end.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Breaking change chaos.&lt;/strong&gt; A breaking change in the proto doesn’t break one build. It breaks every consumer, including the ones you forgot existed. The blast radius of a contract change becomes the whole system.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Version mismatch bugs.&lt;/strong&gt; The copy-paste in payments drifts. It always drifts. Now two services disagree about the contract while both believe they’re current — and the mismatch doesn’t show up in CI, because each service compiles fine against its own copy. It shows up in production, as garbled payloads and errors nobody can reproduce locally.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tight inter-service dependency.&lt;/strong&gt; Underneath all of it: services are depending on each other’s code. Not on an interface — on an implementation, owned by another team, on another team’s release schedule.&lt;/p&gt;

&lt;p&gt;This is not independence. It’s a monolith that pays network latency for its coupling.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: contracts are a product, not a folder
&lt;/h2&gt;

&lt;p&gt;Contracts get their own repo.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;users-service    ──┐
orders-service   ──┼──►  contracts repo (versioned, published)
payments-service ──┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Version it.&lt;/strong&gt; The contracts repo releases like a library: v1.4.2, with breaking changes forcing a new major version instead of ambushing consumers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Publish it.&lt;/strong&gt; Each service pulls the contracts as a versioned package — the same way it pulls any other dependency.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Consume it.&lt;/strong&gt; Every service pins the version it was built against and upgrades deliberately, on its own schedule, after its own tests pass. A team that needs the new field upgrades this sprint; a team that doesn’t stays on the old version until it’s ready.&lt;/p&gt;

&lt;p&gt;And the hard rule that makes it real: &lt;strong&gt;no service imports another service’s code.&lt;/strong&gt; Not the protos, not a shared "utils" package that quietly becomes a coupling channel. Services meet at the contract, nowhere else.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it works
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;API-first architecture.&lt;/strong&gt; The contract becomes the designed artifact — reviewed, versioned, and intentional — instead of a byproduct of whichever service happened to be written first.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Independent deployments.&lt;/strong&gt; A contract change ships as a new version, not a coordinated release. Teams deploy when they’re ready.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Controlled breaking changes.&lt;/strong&gt; Breaking changes still happen; now they’re explicit, versioned, and adopted consumer by consumer instead of detonating everywhere at once.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Strong service boundaries.&lt;/strong&gt; When the only shared thing is a published contract, the boundary between services is real — enforced by the dependency graph, not by convention and good intentions.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The principle
&lt;/h2&gt;

&lt;p&gt;Services depend on abstractions. Not implementations.&lt;/p&gt;

&lt;p&gt;It’s an old rule, and gRPC doesn’t exempt you from it — it just changes what the abstraction is. In a gRPC system, your .proto files are the abstraction. Everything else follows from where you put them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Run the audit
&lt;/h2&gt;

&lt;p&gt;Three questions, answerable in five minutes:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Does one service "own" your proto files?&lt;/li&gt;
&lt;li&gt;Do services import code from each other?&lt;/li&gt;
&lt;li&gt;Are your contracts versioned independently of any single service?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Three yeses to the wrong questions, and the diagnosis writes itself: the services are coupled at the contract layer, and no amount of separate repos, pipelines, or deploy targets changes that.&lt;/p&gt;

&lt;p&gt;Decoupling was never a property of how many services you run. It’s a property of what they’re allowed to depend on.&lt;/p&gt;

&lt;p&gt;If your contracts aren’t independent, your services aren’t either.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I build email infrastructure (rootmail, in beta) and write about backend systems every week. More here: &lt;a href="https://robertpaschal.substack.com" rel="noopener noreferrer"&gt;https://robertpaschal.substack.com&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>microservices</category>
      <category>grpc</category>
      <category>architecture</category>
      <category>backend</category>
    </item>
    <item>
      <title>My site is done. It's not a portfolio — it's a menu.</title>
      <dc:creator>Odinaka Robert Nnamani</dc:creator>
      <pubDate>Wed, 21 Oct 2026 09:16:15 +0000</pubDate>
      <link>https://dev.to/robertpaschal/my-site-is-done-its-not-a-portfolio-its-a-menu-kmi</link>
      <guid>https://dev.to/robertpaschal/my-site-is-done-its-not-a-portfolio-its-a-menu-kmi</guid>
      <description>&lt;p&gt;I finished building heypascal.me this week, and I made a deliberate choice: it's not a portfolio. It's a menu.&lt;/p&gt;

&lt;p&gt;Eight ways to work with me, from a $100 retry-safety audit to a full MVP backend: backend APIs, AWS &amp;amp; deployment, document workflows, retry-safe systems, architecture reviews, MVP backends, technical writing, API docs.&lt;/p&gt;

&lt;p&gt;Everything is scoped and priced up front. I've been on the buying side of the "let's hop on a discovery call" maze enough times to know nobody enjoys it — so I killed it. You see the offering, the scope, the price. Then you decide.&lt;/p&gt;

&lt;p&gt;The retry-safety audit is the one I'm most proud of, honestly. Idempotency keys, duplicate-delivery handling, outbox patterns — the unglamorous stuff that decides whether your API survives its first traffic spike. Most teams don't know they need it until something double-charges a customer.&lt;/p&gt;

&lt;p&gt;If you've got something to build: &lt;a href="https://heypascal.me" rel="noopener noreferrer"&gt;https://heypascal.me&lt;/a&gt;&lt;/p&gt;

</description>
      <category>backend</category>
      <category>freelance</category>
      <category>product</category>
      <category>showdev</category>
    </item>
    <item>
      <title>Every service sends emails — stop that</title>
      <dc:creator>Odinaka Robert Nnamani</dc:creator>
      <pubDate>Tue, 20 Oct 2026 12:39:04 +0000</pubDate>
      <link>https://dev.to/robertpaschal/every-service-sends-emails-stop-that-169d</link>
      <guid>https://dev.to/robertpaschal/every-service-sends-emails-stop-that-169d</guid>
      <description>&lt;p&gt;A mistake I see in many microservice systems:&lt;/p&gt;

&lt;p&gt;Every service sends emails.&lt;/p&gt;

&lt;p&gt;Auth sends emails. Billing sends emails. User service sends emails.&lt;/p&gt;

&lt;p&gt;Soon you have 5 different email implementations, 5 retry strategies, 5 ways to get rate-limited.&lt;/p&gt;

&lt;p&gt;The fix: one notification service. Every service emits events — the notification service owns delivery, retries, rate limits, and user preferences.&lt;/p&gt;

&lt;p&gt;Notifications are deceptively complex. Once you add retries, rate limits, preferences, and multiple channels, it deserves its own service.&lt;/p&gt;

</description>
      <category>backend</category>
      <category>architecture</category>
      <category>microservices</category>
    </item>
  </channel>
</rss>
