<?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: Joung Park</title>
    <description>The latest articles on DEV Community by Joung Park (@joungpark).</description>
    <link>https://dev.to/joungpark</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%2F716955%2Fffdd6810-23b5-413d-94c2-d9830183b088.jpeg</url>
      <title>DEV Community: Joung Park</title>
      <link>https://dev.to/joungpark</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/joungpark"/>
    <language>en</language>
    <item>
      <title>Migrate AMQ to Kafka</title>
      <dc:creator>Joung Park</dc:creator>
      <pubDate>Tue, 27 Oct 2026 02:41:16 +0000</pubDate>
      <link>https://dev.to/joungpark/migrate-amq-to-kafka-2j9e</link>
      <guid>https://dev.to/joungpark/migrate-amq-to-kafka-2j9e</guid>
      <description>&lt;p&gt;As our system moved from a monolith towards microservices, services needed a way to communicate without depending on one another directly. We used ActiveMQ, managed through Amazon MQ, for that messaging. Later, we moved to Kafka on Amazon MSK.&lt;/p&gt;

&lt;p&gt;This was not simply a change of hosting provider. ActiveMQ and Kafka have different messaging models, so the migration made us revisit how we publish, consume, and operate events. Here is why we made the move, what changed, and what I would keep in mind when planning a similar migration.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why move from ActiveMQ to Kafka?
&lt;/h2&gt;

&lt;p&gt;Amazon MQ gave us a managed ActiveMQ broker and a familiar message-broker model. That was a reasonable starting point: services could send messages through a broker instead of calling each other directly.&lt;/p&gt;

&lt;p&gt;As we moved further towards event-driven microservices, Kafka became a better fit for the kind of communication we wanted to build around. Kafka stores records in durable, partitioned logs. Consumers read those records at their own pace, and separate consumer groups can each process the same stream independently.&lt;/p&gt;

&lt;p&gt;That model is useful when an event should be available to more than one downstream service, or when we want consumers to be able to catch up by reading retained events. It also gives us a common platform for event-driven workflows and stream processing.&lt;/p&gt;

&lt;p&gt;The key point is that Kafka was not automatically better for every message. We chose it because its log-and-consumer model fit the direction of the architecture. A traditional broker can still be the right choice for workloads that need its queueing and delivery semantics.&lt;/p&gt;

&lt;h2&gt;
  
  
  The important difference: queue versus log
&lt;/h2&gt;

&lt;p&gt;The biggest conceptual change was moving from thinking of messages as work handed to a broker queue to thinking of events as records in a log.&lt;/p&gt;

&lt;p&gt;With a queue, a message is typically delivered to a consumer, acknowledged, and removed from the work waiting to be processed. With Kafka, a record remains in a topic according to its retention policy. Each consumer group tracks its own position, so multiple groups can independently process the same records.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ActiveMQ queue
Producer ──► Queue ──► Consumer

Kafka topic
Producer ──► Topic (retained log) ──► Consumer group A
                              └─────► Consumer group B
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This changes what consumers are responsible for. A Kafka consumer tracks offsets and must handle the possibility of seeing a record again, for example after a restart or a failure before its progress is committed. In practice, consumers need to be designed for safe retries and duplicate delivery; moving to Kafka does not make processing exactly-once by default.&lt;/p&gt;

&lt;p&gt;Kafka also orders records within a partition, not across an entire topic. If related events need to be processed in order, the producer needs to use a consistent key so those events are routed to the same partition. Ordering is a design decision, not something to assume globally.&lt;/p&gt;

&lt;h2&gt;
  
  
  Considerations when moving from ActiveMQ to Kafka
&lt;/h2&gt;

&lt;p&gt;Two requirements shaped our migration: &lt;strong&gt;keep the live system running without downtime&lt;/strong&gt; and &lt;strong&gt;preserve the ordering our scoring logic depends on&lt;/strong&gt;. Changing the broker was only useful if we could maintain both.&lt;/p&gt;

&lt;h3&gt;
  
  
  Migrating a live system without downtime
&lt;/h3&gt;

&lt;p&gt;Our system was already live, so we needed to move traffic without interrupting message processing. We brought Kafka online, switched the producer over, and kept the ActiveMQ listener available during the monitoring and rollback period:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Create the Kafka topic&lt;/strong&gt; with the required configuration.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Start the Kafka listener&lt;/strong&gt; and confirm it is healthy before sending it production messages.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stop publishing to ActiveMQ and start publishing to Kafka.&lt;/strong&gt; This switches new messages to the new path without intentionally sending each message to both brokers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Monitor the ActiveMQ queue&lt;/strong&gt; to confirm no new messages are arriving and that any messages already queued are draining.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep the ActiveMQ listener running while monitoring Kafka.&lt;/strong&gt; If an issue appears during the cutover, switch publishing back to ActiveMQ while investigating and debugging the Kafka path.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stop the ActiveMQ listener&lt;/strong&gt; only after the Kafka path has been verified and the rollback window has passed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Continue monitoring Kafka.&lt;/strong&gt; Once the new path is stable, remove the old ActiveMQ queue and publishing/listener logic.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Keeping the ActiveMQ listener available gives us a way to back out while the old path is still available. Before stopping it, confirm that the old queue has drained and no messages remain in flight. Monitor Kafka consumer lag, processing errors, and successful downstream score updates—not just whether the listener is running. Growing lag can mean scores are delayed even when the brokers appear healthy.&lt;/p&gt;

&lt;h4&gt;
  
  
  Tip: keep message handling separate from business logic
&lt;/h4&gt;

&lt;p&gt;One thing that made this migration easier was separating the &lt;strong&gt;service layer&lt;/strong&gt; from the &lt;strong&gt;controller/presentation layer&lt;/strong&gt;. Our scoring logic lived in the service layer, while the message listener acted as an entry point that translated an incoming message into a service call.&lt;/p&gt;

&lt;p&gt;Instead of changing the scoring service, we added a Kafka listener alongside the existing ActiveMQ listener. Both called the same service:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ActiveMQ listener ──┐
                    ├──► Scoring service
Kafka listener ─────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This kept the migration focused on the messaging boundary:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Controller/presentation layer:&lt;/strong&gt; receive and translate messages, and handle broker-specific acknowledgements or offset commits.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Service layer:&lt;/strong&gt; apply the scoring rules without depending on whether the request came from ActiveMQ or Kafka.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We could deploy the Kafka entry point before switching publishers, keep the ActiveMQ entry point available for rollback, and remove it after the cutover without rewriting the scoring logic. The practical tip is to &lt;strong&gt;add a new entry point, not a second copy of the business logic&lt;/strong&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Ordering and grouping messages
&lt;/h3&gt;

&lt;p&gt;Our scoring flow needs related messages to be processed in strict order. A delta between snapshots needs the previous state, and a win streak depends on the order of outcomes. Processing those messages concurrently or out of order could cause race conditions and inconsistent score calculations.&lt;/p&gt;

&lt;p&gt;The grouping key must identify the &lt;strong&gt;scoring subject&lt;/strong&gt;—for example, a player ID, match ID, or another stable identifier for the state being calculated. We used:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;ActiveMQ:&lt;/strong&gt; &lt;code&gt;JMSXGroupID&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Kafka:&lt;/strong&gt; the message key&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The technology changed, but the reason for grouping did not: all events contributing to the same score must be processed sequentially.&lt;/p&gt;

&lt;h4&gt;
  
  
  How it works under the hood
&lt;/h4&gt;

&lt;h5&gt;
  
  
  ActiveMQ (&lt;code&gt;JMSXGroupID&lt;/code&gt;)
&lt;/h5&gt;

&lt;p&gt;&lt;code&gt;JMSXGroupID&lt;/code&gt; is a message property that enables JMS message grouping. The broker keeps messages with the same group ID associated with one consumer at a time and delivers them in order within that group.&lt;/p&gt;

&lt;p&gt;The broker maintains the group-to-consumer association. If a consumer disconnects, the group can be reassigned. This provides consumer affinity for a group, while allowing different groups to be processed by different consumers.&lt;/p&gt;

&lt;h5&gt;
  
  
  Kafka (message key)
&lt;/h5&gt;

&lt;p&gt;The message key determines which partition receives a record. With the same partitioning strategy and partition count, records with the same key are routed to the same partition, where Kafka preserves their order:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;AMQ:    JMSXGroupID = player-123  ──► same consumer at a time, in order
Kafka:  key = player-123          ──► same partition, in order
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Within a Kafka consumer group, each partition is assigned to only one consumer at a time. A rebalance can assign that partition to a different consumer, so Kafka provides partition-level ordering rather than permanent key-to-consumer affinity.&lt;/p&gt;

&lt;h4&gt;
  
  
  Implications
&lt;/h4&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Ordering is scoped differently:&lt;/strong&gt; AMQ groups messages by consumer affinity; Kafka orders records within a partition.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Parallelism is across groups or partitions:&lt;/strong&gt; one group is processed sequentially, while separate groups or partitions can be processed concurrently.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hot keys can limit throughput:&lt;/strong&gt; a busy scoring subject can become a hotspot. In Kafka, adding consumers does not increase parallelism beyond the number of partitions, and uneven key distribution can create hot partitions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Choose the key from the state being updated:&lt;/strong&gt; use the same stable scoring-subject identifier for all events that contribute to that calculation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Preserve order in the handler too:&lt;/strong&gt; do not process events for the same scoring subject concurrently. Partition ordering does not prevent application code from completing work out of order.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Design retries for stateful processing:&lt;/strong&gt; duplicate delivery must not apply a score change twice. A failed event also needs an explicit recovery path; skipping it and processing the next snapshot or outcome could produce the wrong score.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The cutover needs the same care. An older event still being processed from ActiveMQ could finish after a newer event from Kafka. Keeping the same scoring identifier in both systems does not create ordering across the two brokers. Before allowing newer events to update a subject's score, ensure its earlier ActiveMQ work has completed. The same concern applies when switching publishing back during rollback.&lt;/p&gt;

&lt;p&gt;If the Kafka message key is not the right fit, alternatives depend on the required guarantee:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Use a separate topic&lt;/strong&gt; when the message flow needs independent scaling, retention, or failure handling—not just because it has a different message type.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Choose an explicit partition&lt;/strong&gt; only when fixed placement is required and the coupling to the topic's partition layout is acceptable. A stable key is generally easier to maintain as the topic scales.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Handle ordering in the application&lt;/strong&gt; with sequence numbers or version checks when strict broker-level ordering is not required. Consumers must then detect and deal with out-of-order or duplicate events.&lt;/li&gt;
&lt;/ul&gt;

&lt;h4&gt;
  
  
  Key takeaway
&lt;/h4&gt;

&lt;p&gt;In both systems, correctness depends on choosing a stable grouping identifier that matches the state being calculated. In our scoring flow, we used &lt;code&gt;JMSXGroupID&lt;/code&gt; with ActiveMQ and the same scoring-subject value as the Kafka message key, so events for that subject stay ordered.&lt;/p&gt;

&lt;h2&gt;
  
  
  The outcome
&lt;/h2&gt;

&lt;p&gt;Moving from ActiveMQ on Amazon MQ to Kafka on Amazon MSK was part of a broader shift in how our services communicate. Kafka gave us a durable event log that multiple consumer groups can read independently, which fits the event-driven architecture we were moving towards.&lt;/p&gt;

&lt;p&gt;The two requirements stayed the same throughout: keep processing live traffic and keep score calculations correct. Separating the listeners from the service layer let us change the messaging entry point without rewriting the scoring logic. Keeping the old path available gave us a rollback option, while choosing the scoring subject as the Kafka key carried our grouping requirement into the new system.&lt;/p&gt;

&lt;p&gt;The main lesson for me is that a messaging migration is not just a broker change. It is about preserving the behavior the application depends on while changing how messages reach it.&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>aws</category>
      <category>backend</category>
      <category>microservices</category>
    </item>
    <item>
      <title>Step-Up Verification: OTP, Magic Link</title>
      <dc:creator>Joung Park</dc:creator>
      <pubDate>Mon, 26 Oct 2026 04:50:35 +0000</pubDate>
      <link>https://dev.to/joungpark/step-up-verification-otp-magic-link-20p3</link>
      <guid>https://dev.to/joungpark/step-up-verification-otp-magic-link-20p3</guid>
      <description>&lt;p&gt;We already use &lt;strong&gt;Firebase Authentication&lt;/strong&gt; for primary login—ID tokens on the client, verified on the backend with the Admin SDK. That answers &lt;strong&gt;who the user is&lt;/strong&gt;. It does not always prove they &lt;strong&gt;still control the account&lt;/strong&gt; at the moment they confirm a payment, change payout details, or export sensitive data. &lt;strong&gt;Step-up verification&lt;/strong&gt;—email &lt;strong&gt;one-time passwords (OTP)&lt;/strong&gt; or magic links—closes that gap.&lt;/p&gt;

&lt;p&gt;Step-up does not replace Firebase. Users keep sending the same ID token; sensitive routes additionally require recent proof via email. Because Firebase is already in the stack, &lt;a href="https://firebase.google.com/docs/auth/web/email-link-auth" rel="noopener noreferrer"&gt;Firebase email-link sign-in&lt;/a&gt; was the obvious first idea. This post compares that shortcut with a &lt;strong&gt;custom OTP&lt;/strong&gt; service layered on the same auth.&lt;/p&gt;

&lt;h2&gt;
  
  
  Primary login vs step-up
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Responsibility&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Firebase (existing)&lt;/td&gt;
&lt;td&gt;Sign-in, session, ID token, &lt;code&gt;firebaseUid&lt;/code&gt; → internal user&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Step-up (new)&lt;/td&gt;
&lt;td&gt;Short-lived proof before payments, billing changes, exports, etc.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The backend pattern stays familiar: verify the Firebase token first, resolve the user, then—for protected actions—check a step-up credential or reject with &lt;code&gt;403&lt;/code&gt; and prompt for verification.&lt;/p&gt;

&lt;h2&gt;
  
  
  When step-up matters
&lt;/h2&gt;

&lt;p&gt;Typical triggers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Confirming or changing &lt;strong&gt;payment&lt;/strong&gt; methods and billing settings&lt;/li&gt;
&lt;li&gt;Approving a &lt;strong&gt;transfer or purchase&lt;/strong&gt; above a threshold&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Exporting&lt;/strong&gt; or deleting account-held data&lt;/li&gt;
&lt;li&gt;Any action where a stolen session should not be enough&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For those flows the API usually needs two answers:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Is the caller authenticated (session or ID token)?&lt;/li&gt;
&lt;li&gt;Have they &lt;strong&gt;recently&lt;/strong&gt; verified via a channel you control (email OTP or equivalent)?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The shortcut we tried first: trigger Firebase email-link sign-in, then read &lt;strong&gt;last sign-in time&lt;/strong&gt; and &lt;strong&gt;&lt;code&gt;sign_in_provider&lt;/code&gt;&lt;/strong&gt; from the ID token or Admin SDK user record. If the provider indicated email link and sign-in fell within a window (for example 15 minutes), allow the action—no new OTP tables or send endpoints.&lt;/p&gt;

&lt;p&gt;That only works—&lt;strong&gt;if&lt;/strong&gt; the metadata matches what the user actually did.&lt;/p&gt;

&lt;h2&gt;
  
  
  Options considered
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Firebase email-link step-up&lt;/th&gt;
&lt;th&gt;Custom OTP&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Implementation&lt;/td&gt;
&lt;td&gt;Reuse Firebase send/link APIs + session metadata&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;POST /otp/send&lt;/code&gt;, &lt;code&gt;POST /otp/verify&lt;/code&gt;, DB, email&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;User steps&lt;/td&gt;
&lt;td&gt;Open email, tap link (device matters)&lt;/td&gt;
&lt;td&gt;Open email, type or paste code (any device)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Proof on backend&lt;/td&gt;
&lt;td&gt;Last login time + &lt;code&gt;sign_in_provider&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;OTP record + short-lived step-up token&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ops burden&lt;/td&gt;
&lt;td&gt;Low if metadata is trustworthy&lt;/td&gt;
&lt;td&gt;You own security, delivery, and abuse controls&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Security concerns
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Firebase email-link step-up
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Weak backend proof&lt;/strong&gt; — If you gate on &lt;code&gt;sign_in_provider&lt;/code&gt; and last login, a normal password sign-in can look like step-up; policy mistakes can skip re-verification entirely.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Link as bearer secret&lt;/strong&gt; — Anyone with the magic link can consume it; forwarded email or a shared inbox completes the flow.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Full sign-in side effects&lt;/strong&gt; — Email link is real authentication, not a scoped step-up token—sessions refresh, audit trails mix login with “confirm payment,” and revocation semantics are coarser.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Device binding&lt;/strong&gt; — Links opened on the wrong device can strand or confuse users; support pressure rises before security does.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Custom OTP
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Your threat model&lt;/strong&gt; — Brute force, credential stuffing on &lt;code&gt;/otp/verify&lt;/code&gt;, and OTP spraying via &lt;code&gt;/otp/send&lt;/code&gt; need rate limits, lockouts, and monitoring you implement and operate.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Storage and logs&lt;/strong&gt; — Plaintext OTPs in the database or logs are a common failure mode; hash at rest and never log codes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Email channel&lt;/strong&gt; — Same mailbox risks as a magic link; a short numeric code can be easier to phish or shoulder-surf if UX is careless.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Step-up token lifecycle&lt;/strong&gt; — Issue tight TTL, bind to user (and optionally device/session), and reject replay; otherwise a verified OTP window becomes a long-lived bypass.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Option 1: Firebase sign-in via email link
&lt;/h2&gt;

&lt;p&gt;Firebase documents this flow as passwordless email-link authentication on the web—see &lt;a href="https://firebase.google.com/docs/auth/web/email-link-auth" rel="noopener noreferrer"&gt;Authenticate with Firebase Using Email Link&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why it is attractive
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Reuse existing Firebase auth&lt;/strong&gt; — same project, Admin SDK, and email patterns as primary login.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No OTP product to build&lt;/strong&gt; — Firebase generates and validates the link; you focus on when to require step-up.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deep links&lt;/strong&gt; — mobile clients can resume in-app after the user taps the link.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Continue URLs / query params&lt;/strong&gt; — return the user to the payment or settings screen that triggered verification.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Where it breaks down
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cross-device UX&lt;/strong&gt; — magic links often complete on the device that opened the email. Starting checkout on phone and opening mail on a laptop breaks the flow unless you invest in handoff.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Session edge cases&lt;/strong&gt; — expired links, stale tabs, and multiple active sessions need explicit testing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;POC friction&lt;/strong&gt; — a quick spike often surfaces integration details that are not obvious from the docs; Firebase handles sign-in, not necessarily your step-up policy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The blocker: &lt;code&gt;sign_in_provider&lt;/code&gt;&lt;/strong&gt; — after email-link sign-in, Firebase reports &lt;code&gt;sign_in_provider&lt;/code&gt; as &lt;strong&gt;&lt;code&gt;password&lt;/code&gt;&lt;/strong&gt;, not a distinct email-link provider. You cannot tell “just completed email link step-up” from “signed in with email/password” using provider alone. &lt;strong&gt;Last login time + provider is an unreliable gate&lt;/strong&gt; before a payment or similar action.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Unless you add another durable signal (custom claims from a Cloud Function on link completion, a dedicated step-up flag, etc.), Option 1 does not give the backend clean, auditable step-up proof.&lt;/p&gt;

&lt;h2&gt;
  
  
  Option 2: Custom OTP
&lt;/h2&gt;

&lt;p&gt;Firebase stays primary auth. Step-up is a &lt;strong&gt;narrow API&lt;/strong&gt; beside it: send a code to the email on the Firebase user (or your internal profile), verify it, issue a &lt;strong&gt;short-lived step-up token&lt;/strong&gt; (or a server-side “verified until” timestamp keyed by user id).&lt;/p&gt;

&lt;h3&gt;
  
  
  Pros
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Explicit proof&lt;/strong&gt; — the backend knows an OTP was sent, verified, consumed, and when.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;UX control&lt;/strong&gt; — codes work when email is read on another device; resend, cooldown, and validation UI are yours.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One pattern, many gates&lt;/strong&gt; — the same flow can guard payments, billing changes, and exports.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No Firebase metadata quirks&lt;/strong&gt; — step-up does not depend on &lt;code&gt;sign_in_provider&lt;/code&gt; semantics.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Cons
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Build and maintain&lt;/strong&gt; — generation, hashing, expiry, single-use, templates, monitoring (see security notes above).&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Flow
&lt;/h3&gt;



&lt;pre data-lang="mermaid"&gt;&lt;code&gt;sequenceDiagram
    participant U as User
    participant C as Web / Mobile Client
    participant A as Backend API
    participant D as Database
    participant E as Email Service

    Note over U,E: Send OTP
    U-&amp;gt;&amp;gt;C: Start protected action (e.g. confirm payment)
    C-&amp;gt;&amp;gt;A: POST /otp/send (Firebase ID token)
    A-&amp;gt;&amp;gt;A: Verify token, resolve user
    A-&amp;gt;&amp;gt;A: Generate OTP, store hash + expiry
    A-&amp;gt;&amp;gt;E: Send email with code
    E--&amp;gt;&amp;gt;U: Email delivered
    A--&amp;gt;&amp;gt;C: 200 OK

    Note over U,E: Verify OTP
    U-&amp;gt;&amp;gt;C: Enter code
    C-&amp;gt;&amp;gt;A: POST /otp/verify (code)
    A-&amp;gt;&amp;gt;D: Load pending OTP for user
    alt Valid and not expired
        A-&amp;gt;&amp;gt;D: Mark OTP used
        A-&amp;gt;&amp;gt;A: Issue step-up token or session flag
        A--&amp;gt;&amp;gt;C: 200 OK
        C--&amp;gt;&amp;gt;U: Continue protected action
    else Invalid or expired
        A--&amp;gt;&amp;gt;C: 401 / 400
        C--&amp;gt;&amp;gt;U: Retry or resend
    end&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;In words:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Send&lt;/strong&gt; — Client calls &lt;code&gt;/otp/send&lt;/code&gt; with the usual Firebase ID token. Backend verifies it, resolves the user, stores a &lt;strong&gt;hashed&lt;/strong&gt; OTP with expiry, sends email. Never store plaintext codes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Verify&lt;/strong&gt; — Client submits the code. Backend checks hash, expiry, and single-use, marks the OTP consumed, returns a &lt;strong&gt;step-up credential&lt;/strong&gt; (JWT claim, cookie, or server-side “verified until”).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Protected APIs&lt;/strong&gt; — Require a fresh step-up credential within the policy window in addition to normal auth—for example before submitting a charge or updating a payout account.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Implementation notes (custom OTP)
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Hash OTPs&lt;/strong&gt; at rest; short TTL (for example 10–15 minutes); one active send per user with resend cooldown.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rate limit&lt;/strong&gt; send and verify; cap attempts per OTP before invalidation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Email&lt;/strong&gt; — state the action (“Confirm payment”, “Update billing”) so users know why they received a code.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Audit&lt;/strong&gt; — log send/verify outcomes without logging the code.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Clients&lt;/strong&gt; — one backend contract; web and mobile differ only in how they collect the code.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;p&gt;We wanted step-up without ripping out Firebase. Email-link step-up is the natural fit, but &lt;strong&gt;&lt;code&gt;sign_in_provider&lt;/code&gt; reporting &lt;code&gt;password&lt;/code&gt; breaks the simple “provider + last login” rule&lt;/strong&gt;. Patching that with custom claims or Cloud Functions adds moving parts—and still blurs sign-in with step-up.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Custom OTP&lt;/strong&gt; is more code, but it sits cleanly &lt;strong&gt;on top of&lt;/strong&gt; existing Firebase auth: same ID token at the edge, plus an explicit step-up proof the API can enforce before payments and other sensitive work.&lt;/p&gt;

&lt;p&gt;A practical rollout: ship a thin vertical slice (send + verify + one protected endpoint, such as payment confirmation) behind a feature flag, and load-test rate limits before widening coverage.&lt;/p&gt;

</description>
      <category>authentication</category>
      <category>backend</category>
      <category>security</category>
    </item>
    <item>
      <title>Share a Kafka Topic Only When Order Matters</title>
      <dc:creator>Joung Park</dc:creator>
      <pubDate>Mon, 19 Oct 2026 05:16:34 +0000</pubDate>
      <link>https://dev.to/joungpark/share-a-kafka-topic-only-when-order-matters-4dlp</link>
      <guid>https://dev.to/joungpark/share-a-kafka-topic-only-when-order-matters-4dlp</guid>
      <description>&lt;p&gt;We merged Kafka topics because the same services consumed the messages together. It felt simpler. Two incidents showed what we had really done: one shared topic means one shared queue, one failure domain, and one on-call problem—not a tidy folder in a repo.&lt;/p&gt;

&lt;p&gt;My default rule: &lt;strong&gt;share a topic only when order matters&lt;/strong&gt; for a clear stream (same partition key, same lifecycle). If urgency, retries, batch vs live traffic, or ownership differ, split the topics. Below I explain why we merged, what broke in production, when one topic is still right, and defaults that help teams avoid the same mistakes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why we merged topics
&lt;/h2&gt;

&lt;p&gt;We had several topics. The same services read the events. Dashboards grouped them. We used the same domain words (“orders,” “billing,” “notifications”). Merging felt reasonable:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Fewer topic names and fewer ACL/schema subjects to manage&lt;/li&gt;
&lt;li&gt;One obvious place per business area&lt;/li&gt;
&lt;li&gt;One mental pipeline for producers and consumers&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We also followed a familiar rule of thumb: &lt;strong&gt;combine topics that are mostly consumed together.&lt;/strong&gt; If the same consumers normally subscribe to all of them, one topic can be simpler. Consumers can skip message types they do not handle—that is acceptable. Fetching from Kafka is cheap; ignoring even a large share of messages on a topic is often not a big cost. You only need a separate topic when consumers would ignore the &lt;em&gt;vast majority&lt;/em&gt; of messages.&lt;/p&gt;

&lt;p&gt;We put &lt;strong&gt;message type in the header&lt;/strong&gt; so each record could be parsed and routed to the right handler. That made “one topic, many processes” feel safe: separate code paths without separate topic names.&lt;/p&gt;

&lt;p&gt;That made merging feel low-risk. The incidents below showed we had still missed something.&lt;/p&gt;

&lt;h2&gt;
  
  
  What that advice does not cover
&lt;/h2&gt;

&lt;p&gt;Co-consumption answers &lt;em&gt;who reads the stream&lt;/em&gt;. It does not spell out &lt;em&gt;what guarantees the log must provide&lt;/em&gt;—ordering, retention, retries, and what happens when one message type misbehaves.&lt;/p&gt;

&lt;p&gt;You can join streams in application code, materialized views, or analytics. A Kafka topic is not a join key. It is an append-only log with contracts for ordering and retention. Headers that distinguish message type help you &lt;strong&gt;dispatch&lt;/strong&gt; inside the consumer; they do not give you separate partitions, consumer lag, or retry boundaries. When message types need different contracts, one topic still ties them together. You often only notice that under load or during failure—not when consumers happily skip or route messages in code.&lt;/p&gt;

&lt;h2&gt;
  
  
  Incident 1: Bulk traffic blocked important work
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Setup.&lt;/strong&gt; Topic &lt;code&gt;billing.events&lt;/code&gt;, partition key &lt;code&gt;accountId&lt;/code&gt;, already handled live &lt;code&gt;payment-captured&lt;/code&gt; and &lt;code&gt;invoice-issued&lt;/code&gt;. Later we added &lt;code&gt;ledger-export-row&lt;/code&gt; to the same topic so the export job could reuse the same pipeline and &lt;code&gt;accountId&lt;/code&gt; keying. One consumer group still processed every message type in partition order.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What happened.&lt;/strong&gt; After &lt;code&gt;ledger-export-row&lt;/code&gt; joined &lt;code&gt;billing.events&lt;/code&gt;, a heavy export run—or backlog after an outage—filled partitions for busy &lt;code&gt;accountId&lt;/code&gt; values. &lt;code&gt;payment-captured&lt;/code&gt; and &lt;code&gt;invoice-issued&lt;/code&gt; kept flowing as before, but new records queued behind export rows on the same partitions. The consumer could not prefer the live types that had been fine on their own; offset order now applied to &lt;code&gt;ledger-export-row&lt;/code&gt; too. Lag on &lt;code&gt;payment-captured&lt;/code&gt; and &lt;code&gt;invoice-issued&lt;/code&gt; climbed while the group chewed through export rows at a fixed rate.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Symptoms.&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;End-to-end latency alerts on &lt;code&gt;payment-captured&lt;/code&gt; / &lt;code&gt;invoice-issued&lt;/code&gt; flows while &lt;code&gt;ledger-export-row&lt;/code&gt; lag was high on &lt;code&gt;billing.events&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Support reports: payments or invoices “stuck for hours” with no export job visibly “broken”&lt;/li&gt;
&lt;li&gt;Healthy brokers, growing consumer lag on the shared group consuming &lt;code&gt;billing.events&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Mitigation.&lt;/strong&gt; We stopped the &lt;code&gt;ledger-export-row&lt;/code&gt; producer, watched lag on &lt;code&gt;billing.events&lt;/code&gt;, and waited until the consumer drained enough backlog that &lt;code&gt;payment-captured&lt;/code&gt; and &lt;code&gt;invoice-issued&lt;/code&gt; caught up. It worked, but only as a manual lever—identify the bulk source, turn it off, hope you chose the right one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson.&lt;/strong&gt; Live types on a topic do not stay safe when you add bulk later. Do not park &lt;code&gt;ledger-export-row&lt;/code&gt;-class export on the same partition-ordered topic as &lt;code&gt;payment-captured&lt;/code&gt; and &lt;code&gt;invoice-issued&lt;/code&gt; unless you accept that slow export blocks fast billing for that key. Move export back to its own topic (for example &lt;code&gt;billing.export-replay&lt;/code&gt;) or isolate it with a separate consumer group.&lt;/p&gt;

&lt;h2&gt;
  
  
  Incident 2: One broken process blocked everything on the topic
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Setup.&lt;/strong&gt; Topic &lt;code&gt;order.events&lt;/code&gt;, partition key &lt;code&gt;orderId&lt;/code&gt;. A &lt;strong&gt;message-type&lt;/strong&gt; header routed each record—for example &lt;code&gt;order-placed&lt;/code&gt;, &lt;code&gt;payment-authorized&lt;/code&gt;, and &lt;code&gt;return-initiated&lt;/code&gt;—to its own handler with different validation and idempotency rules. Separate code paths, but still &lt;strong&gt;one topic&lt;/strong&gt; and &lt;strong&gt;one consumer group&lt;/strong&gt; per partition, processed in offset order.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What happened.&lt;/strong&gt; A &lt;code&gt;return-initiated&lt;/code&gt; message was built from bad data in the database (invalid refund amount on the return record). The returns handler rejected it, the message retried, and the consumer could not commit past that offset on the partition. Later records on the same partition—including &lt;code&gt;order-placed&lt;/code&gt; and &lt;code&gt;payment-authorized&lt;/code&gt; for other orders that hashed to that partition—waited behind the failing &lt;code&gt;return-initiated&lt;/code&gt;. Headers picked the right handler; they did not unblock the shared queue.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Symptoms.&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Log spikes for &lt;code&gt;return-initiated&lt;/code&gt; validation failures on &lt;code&gt;order.events&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Stalled processing for &lt;code&gt;order-placed&lt;/code&gt; / &lt;code&gt;payment-authorized&lt;/code&gt; on the same partition despite healthy payloads&lt;/li&gt;
&lt;li&gt;Retries on the bad &lt;code&gt;return-initiated&lt;/code&gt; record amplifying load; on-call debating skip, seek, or redeploy on &lt;code&gt;order.events&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Mitigation.&lt;/strong&gt; We fixed the invalid data in the database—the source of the bad refund amount—then replayed or retried until the &lt;code&gt;return-initiated&lt;/code&gt; handler could succeed and the partition offset could move. &lt;code&gt;order-placed&lt;/code&gt; and &lt;code&gt;payment-authorized&lt;/code&gt; traffic resumed on that partition. Patching DB rows to unblock a Kafka partition was slow, risky, and coordination-heavy; it should not be the routine way to recover &lt;code&gt;order.events&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson.&lt;/strong&gt; Routing &lt;code&gt;return-initiated&lt;/code&gt; by header is not the same as isolating it. One topic still shares retry policy and poison-message risk across types—and a bad row in the database can stall unrelated message types on the same partition. A dedicated topic (for example &lt;code&gt;order.returns&lt;/code&gt;) or an isolated consumer + DLQ for &lt;code&gt;return-initiated&lt;/code&gt; shrinks blast radius so returns work does not hold up fulfillment or payment events.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule: share a topic only when order matters
&lt;/h2&gt;

&lt;p&gt;Share a topic when all of this is true:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Events form one logical sequence for one id (aggregate, entity, or workflow instance).&lt;/li&gt;
&lt;li&gt;Consumers must process that sequence in order to stay correct—not just for easier debugging.&lt;/li&gt;
&lt;li&gt;You can name the partition key for that sequence (&lt;code&gt;orderId&lt;/code&gt;, &lt;code&gt;accountId&lt;/code&gt;, &lt;code&gt;shardId&lt;/code&gt;, …).&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Kafka orders messages within a partition. It does not order across keys. It does not let you prioritize message type A over type B on the same partition.&lt;/p&gt;

&lt;h3&gt;
  
  
  When one topic is the right model
&lt;/h3&gt;

&lt;p&gt;Example: account lifecycle for one account.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Topic:&lt;/strong&gt; &lt;code&gt;account.events&lt;/code&gt; (or your environment prefix)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Key:&lt;/strong&gt; &lt;code&gt;accountId&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Events:&lt;/strong&gt; &lt;code&gt;account-opened&lt;/code&gt; → &lt;code&gt;limit-set&lt;/code&gt; → &lt;code&gt;debit-applied&lt;/code&gt; → &lt;code&gt;account-closed&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you rebuild account state from the log, you need order per account. One topic, stable keys, and documented schema changes fit well.&lt;/p&gt;

&lt;h3&gt;
  
  
  When the same domain still needs multiple topics
&lt;/h3&gt;

&lt;p&gt;You may still say “account” or “order” in conversation—but need different guarantees:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Concern&lt;/th&gt;
&lt;th&gt;Split because&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Live lifecycle vs bulk backfill&lt;/td&gt;
&lt;td&gt;Different urgency and lag tolerance&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Commands vs analytics fan-out&lt;/td&gt;
&lt;td&gt;Strict order for state; best-effort for metrics/search&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Process A vs process B handlers&lt;/td&gt;
&lt;td&gt;Different failures, retries, and owners&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Notifications vs ledger&lt;/td&gt;
&lt;td&gt;Different SLOs and idempotency rules&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;order.lifecycle&lt;/code&gt; — key = &lt;code&gt;orderId&lt;/code&gt;, state machine, strict order&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;order.backfill&lt;/code&gt; — bulk replay, isolated consumers, not on the live path&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;order.notifications&lt;/code&gt; — key = &lt;code&gt;orderId&lt;/code&gt; or &lt;code&gt;userId&lt;/code&gt;, own retry/DLQ&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;For experienced teams:&lt;/strong&gt; Draw topic boundaries around SLOs and failure domains. DDD boxes can guide naming; the log contract is what you must get right.&lt;/p&gt;

&lt;h2&gt;
  
  
  Recommendations
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Do not share a topic when…
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Interactive and batch/export/replay traffic need different latency.&lt;/li&gt;
&lt;li&gt;One message type can poison the queue or retry forever while others are fine.&lt;/li&gt;
&lt;li&gt;Different teams ship on different cycles—you do not want one bad deploy to block another team’s events.&lt;/li&gt;
&lt;li&gt;One event type can spike 10× and define lag for everything else.&lt;/li&gt;
&lt;li&gt;“We read them together” is only true in a UI or report—join in the app, not in the broker.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;message-type header&lt;/strong&gt; (or similar) routes work in code—it does not replace separate topics when lag, retries, or failure domains must differ.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Creating a new topic per feature is usually correct
&lt;/h3&gt;

&lt;p&gt;Teams ask: “Will we drown in topics?” Creating topics is cheap. Coupled outages are expensive. A feature with its own producers, consumers, retention, and on-call owner deserves its own topic name and a clear ordering story—not a slot on a crowded legacy topic because the noun matches.&lt;/p&gt;

&lt;p&gt;What grows with topic count is governance: ACLs, Schema Registry subjects, dashboards, naming rules—not a cliff where the cluster stops accepting topics. Invest there instead of minimizing topic count for its own sake.&lt;/p&gt;

&lt;h3&gt;
  
  
  Defaults worth putting in team standards
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Document what the partition key means for “order.”&lt;/li&gt;
&lt;li&gt;One consumer group per service that owns processing; do not share groups to save machines.&lt;/li&gt;
&lt;li&gt;DLQ and retry policy per pipeline—not one policy for unrelated handlers on a mega-topic.&lt;/li&gt;
&lt;li&gt;Backfill on dedicated topics or controlled replay; do not mix with live traffic without a design review.&lt;/li&gt;
&lt;li&gt;Runbooks: owner, retention, key, ordering guarantee, and what to do when lag spikes—without “patch the database to unblock the partition” as step one.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Request/reply and channel separation
&lt;/h3&gt;

&lt;p&gt;In many microservice setups (including NestJS/Kafka-style patterns), request and reply use two topics. That is on purpose: different consumers, correlation rules, and scaling. Same idea—order may matter per correlation id on each channel, not “one topic for all RPC-shaped traffic.”&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing
&lt;/h2&gt;

&lt;p&gt;We merged topics because consumption looked shared. Incident 1: bulk and urgent traffic must not share an ordered queue if recovery means stopping producers and waiting. Incident 2: unrelated processes on one topic share retries and poison messages—and fixing bad rows in the database to unblock the log is a bad habit, not a strategy.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Default:&lt;/strong&gt; Share a topic only when order matters for a defined key and lifecycle. Split on urgency, failure isolation, and ownership before the next 2 a.m. drain.&lt;/p&gt;

&lt;p&gt;This week, pick one busy shared topic and ask: mixed urgency? mixed handlers? one retry policy for everything? If yes, plan a split—the incidents above are what that review prevents.&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>backend</category>
      <category>devops</category>
      <category>systemdesign</category>
    </item>
    <item>
      <title>Find Similar Sentences Using Embeddings and a Vector Database</title>
      <dc:creator>Joung Park</dc:creator>
      <pubDate>Thu, 15 Oct 2026 12:05:56 +0000</pubDate>
      <link>https://dev.to/joungpark/find-similar-sentences-using-embeddings-and-a-vector-database-mej</link>
      <guid>https://dev.to/joungpark/find-similar-sentences-using-embeddings-and-a-vector-database-mej</guid>
      <description>&lt;h2&gt;
  
  
  Why I Started Exploring Semantic Search
&lt;/h2&gt;

&lt;p&gt;I'm building Second Memory, a personal memory application that allows users to save their memories and retrieve them later through natural-language questions.&lt;/p&gt;

&lt;p&gt;One challenge is that users don't necessarily remember the exact words they used when saving a memory. They might remember its context or meaning instead.&lt;/p&gt;

&lt;p&gt;For example, a user might have saved these memories:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;"I forgot my umbrella and got soaked in the rain."&lt;/li&gt;
&lt;li&gt;"I lifted weights at the gym after work."&lt;/li&gt;
&lt;li&gt;"I caught up with John over coffee yesterday."&lt;/li&gt;
&lt;li&gt;"I stayed up late debugging my application."&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Later, the user asks:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"Who did I meet recently?"&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The most relevant memory would be:&lt;/p&gt;

&lt;p&gt;"I caught up with John over coffee yesterday."&lt;/p&gt;

&lt;p&gt;A traditional keyword search might miss this because "meet" and "caught up" are different expressions.&lt;/p&gt;

&lt;p&gt;This is where &lt;strong&gt;semantic search&lt;/strong&gt; comes in. Instead of relying on exact keyword matches, we can search for text with similar meanings using embeddings.&lt;/p&gt;

&lt;p&gt;In this post, I'll explore how to generate embeddings, calculate cosine distance, find relevant sentences, and perform vector searches using PostgreSQL with pgvector.&lt;/p&gt;

&lt;p&gt;The examples use a locally runnable embedding model, so no API key or paid embedding service is required.&lt;/p&gt;

&lt;h2&gt;
  
  
  Generate Embeddings
&lt;/h2&gt;

&lt;p&gt;An embedding is a numerical representation of text. An embedding model converts text into a vector, allowing us to compare sentences mathematically.&lt;/p&gt;

&lt;p&gt;For this demonstration, I'll use &lt;code&gt;sentence-transformers/all-MiniLM-L6-v2&lt;/code&gt;, a small, open-source embedding model that runs locally and produces 384-dimensional vectors.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;sentence_transformers&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;SentenceTransformer&lt;/span&gt;

&lt;span class="n"&gt;model&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;SentenceTransformer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sentence-transformers/all-MiniLM-L6-v2&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;sentences&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;I forgot my umbrella and got soaked in the rain.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;I lifted weights at the gym after work.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;I caught up with John over coffee yesterday.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;I stayed up late debugging my application.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="n"&gt;embeddings&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sentences&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;embeddings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;shape&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;# (4, 384)
&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;embeddings&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;# [[ 0.04035296  0.10288464  0.09306896 ... -0.00502765 -0.13130508  0.05253862]
#  [-0.07009698  0.04319851  0.00160278 ... -0.06155823 -0.09415536 -0.01941661]
#  [-0.04802262 -0.01395144  0.12706237 ... -0.0639021  -0.03755342 -0.06052431]
#  [ 0.00379441  0.01192298 -0.02876158 ... -0.02021407 -0.0892337   0.05982817]]
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The model converts each sentence into a vector containing 384 floating-point values.&lt;/p&gt;

&lt;p&gt;The shape &lt;code&gt;(4, 384)&lt;/code&gt; represents four sentences, each with a 384-dimensional embedding.&lt;/p&gt;

&lt;p&gt;Each number is a coordinate in a high-dimensional vector space. Individual dimensions don't necessarily correspond to specific concepts such as people, places, or activities. Instead, semantic information is distributed across the vector.&lt;/p&gt;

&lt;p&gt;The output is abbreviated with &lt;code&gt;...&lt;/code&gt;, but each embedding contains all 384 values.&lt;/p&gt;

&lt;p&gt;Looking at these numbers alone doesn't tell us which sentences are semantically related. To find that out, we need to compare their vectors.&lt;/p&gt;
&lt;h3&gt;
  
  
  Using an Embedding API
&lt;/h3&gt;

&lt;p&gt;Embeddings can also be generated through an API rather than running a model locally.&lt;/p&gt;

&lt;p&gt;For example, OpenAI provides an embeddings endpoint:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl https://api.openai.com/v1/embeddings &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$OPENAI_API_KEY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{
    "model": "text-embedding-3-small",
    "input": "I caught up with John over coffee yesterday."
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The response contains an embedding vector, just like the local model.&lt;/p&gt;

&lt;p&gt;The concept is the same, although different models produce different vector dimensions and values. Embeddings generated by different models generally shouldn't be mixed in the same similarity search.&lt;/p&gt;

&lt;p&gt;For the rest of this post, I'll continue using the local model so the examples remain reproducible without an API key.&lt;/p&gt;
&lt;h2&gt;
  
  
  Calculate Cosine Distance
&lt;/h2&gt;

&lt;p&gt;Now that we have embeddings, we need a way to measure how closely two sentences are related.&lt;/p&gt;

&lt;p&gt;One commonly used metric is &lt;strong&gt;cosine distance&lt;/strong&gt;, which compares the directions of two vectors rather than their magnitudes.&lt;/p&gt;

&lt;p&gt;Let's calculate the distance between two sentences.&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;numpy&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;sentence_transformers&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;SentenceTransformer&lt;/span&gt;

&lt;span class="n"&gt;model&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;SentenceTransformer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sentence-transformers/all-MiniLM-L6-v2&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;embeddings&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;I forgot my umbrella and got soaked in the rain.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Who did I meet recently?&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;normalize_embeddings&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# Cosine distance using normalized vectors
&lt;/span&gt;&lt;span class="n"&gt;distance&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;embeddings&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="n"&gt;embeddings&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;

&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Cosine distance: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;distance&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;# Cosine distance: 0.7981
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The &lt;code&gt;normalize_embeddings=True&lt;/code&gt; option scales each vector to unit length.&lt;/p&gt;

&lt;p&gt;For normalized vectors, the dot product (&lt;code&gt;np.dot()&lt;/code&gt;) is equivalent to cosine similarity. Subtracting it from 1 gives us cosine distance:&lt;/p&gt;

&lt;p&gt;

&lt;/p&gt;
&lt;div class="katex-element"&gt;
  &lt;span class="katex-display"&gt;&lt;span class="katex"&gt;&lt;span class="katex-mathml"&gt;&lt;/span&gt;&lt;span class="katex-html"&gt;&lt;span class="base"&gt;&lt;span class="strut"&gt;&lt;/span&gt;&lt;span class="mord text"&gt;&lt;span class="mord"&gt;cosine&amp;nbsp;distance&lt;/span&gt;&lt;/span&gt;&lt;span class="mspace"&gt;&lt;/span&gt;&lt;span class="mrel"&gt;=&lt;/span&gt;&lt;span class="mspace"&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="base"&gt;&lt;span class="strut"&gt;&lt;/span&gt;&lt;span class="mord"&gt;1&lt;/span&gt;&lt;span class="mspace"&gt;&lt;/span&gt;&lt;span class="mbin"&gt;−&lt;/span&gt;&lt;span class="mspace"&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="base"&gt;&lt;span class="strut"&gt;&lt;/span&gt;&lt;span class="mord text"&gt;&lt;span class="mord"&gt;cosine&amp;nbsp;similarity&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;/div&gt;



&lt;p&gt;Sentence Transformers also provides &lt;code&gt;model.similarity()&lt;/code&gt;, which calculates cosine similarity by default. The equivalent calculation would be:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;distance&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;similarity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;embeddings&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="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;embeddings&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;item&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;However, using NumPy keeps the distance calculation independent of Sentence Transformers. The same approach works with embeddings returned by OpenAI or other embedding APIs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A smaller cosine distance indicates greater similarity.&lt;/strong&gt; A distance of 0 means the vectors point in the same direction.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why Cosine Distance?
&lt;/h3&gt;

&lt;p&gt;There are several ways to compare vectors:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Description&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Cosine distance&lt;/td&gt;
&lt;td&gt;Compares vector directions, ignoring magnitude&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Euclidean distance&lt;/td&gt;
&lt;td&gt;Measures straight-line distance between vectors&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Inner product&lt;/td&gt;
&lt;td&gt;Measures vector alignment and is affected by magnitude&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For text embeddings, we're generally interested in semantic relationships rather than differences in vector magnitude, making cosine distance a practical default.&lt;/p&gt;

&lt;p&gt;When embeddings are L2-normalized, cosine distance, Euclidean distance, and inner product produce equivalent rankings. The choice of metric matters more when embeddings aren't normalized or when the model recommends a particular metric.&lt;/p&gt;

&lt;p&gt;We'll use cosine distance throughout this post because pgvector supports it directly through the &lt;code&gt;&amp;lt;=&amp;gt;&lt;/code&gt; operator.&lt;/p&gt;

&lt;h2&gt;
  
  
  Find the Most Relevant Memory
&lt;/h2&gt;

&lt;p&gt;Now let's apply cosine distance to our original example and find the memory most relevant to the question:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"Who did I meet recently?"&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;numpy&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;sentence_transformers&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;SentenceTransformer&lt;/span&gt;

&lt;span class="n"&gt;model&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;SentenceTransformer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sentence-transformers/all-MiniLM-L6-v2&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;texts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;I forgot my umbrella and got soaked in the rain.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;I lifted weights at the gym after work.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;I caught up with John over coffee yesterday.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;I stayed up late debugging my application.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="n"&gt;query&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Who did I meet recently?&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="n"&gt;memory_embeddings&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;texts&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;normalize_embeddings&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;query_embedding&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;normalize_embeddings&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# Cosine distance using normalized vectors
&lt;/span&gt;&lt;span class="n"&gt;distances&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;memory_embeddings&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;query_embedding&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;argsort&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;distances&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;distances&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; - &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;texts&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;# 0.6097 - I caught up with John over coffee yesterday.
# 0.7981 - I forgot my umbrella and got soaked in the rain.
# 0.8151 - I lifted weights at the gym after work.
# 0.8454 - I stayed up late debugging my application.
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This time, we're comparing a single query against multiple candidate memories.&lt;/p&gt;

&lt;p&gt;The results are sorted in ascending order because &lt;strong&gt;smaller cosine distance indicates greater relevance&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;As expected, the memory about meeting John ranks first, even though the question uses "meet" while the memory uses "caught up".&lt;/p&gt;

&lt;p&gt;This demonstrates semantic retrieval: finding related meanings rather than matching exact words.&lt;/p&gt;

&lt;p&gt;However, the closest result isn't guaranteed to be relevant in every case. The ranking depends on the embedding model and the available memories.&lt;/p&gt;

&lt;p&gt;In a production system, we'd need to evaluate retrieval quality using representative queries and data.&lt;/p&gt;

&lt;h2&gt;
  
  
  Introducing pgvector
&lt;/h2&gt;

&lt;p&gt;So far, we've generated embeddings and calculated distances in Python. But generating embeddings for every stored memory on each query would be inefficient.&lt;/p&gt;

&lt;p&gt;A better approach is to generate embeddings when memories are created or updated, persist them, and search against the stored vectors.&lt;/p&gt;

&lt;p&gt;For this, I'll use &lt;strong&gt;pgvector&lt;/strong&gt;, a PostgreSQL extension that adds vector storage and similarity search capabilities.&lt;/p&gt;

&lt;p&gt;Before using it with text embeddings, let's look at a simple example with two-dimensional vectors.&lt;/p&gt;

&lt;h3&gt;
  
  
  Store Vectors
&lt;/h3&gt;

&lt;p&gt;First, enable the extension and create a table:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;EXTENSION&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="n"&gt;vector&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;examples&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="nb"&gt;SERIAL&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;embedding&lt;/span&gt; &lt;span class="n"&gt;VECTOR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&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;Insert three vectors:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;examples&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;embedding&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;VALUES&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'[1,0]'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'[0.8,0.6]'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'[0,1]'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Using two-dimensional vectors makes it easier to see how pgvector calculates distances.&lt;/p&gt;

&lt;h3&gt;
  
  
  Search by Cosine Distance
&lt;/h3&gt;

&lt;p&gt;Let's find the vectors closest to &lt;code&gt;[1,0]&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;
    &lt;span class="n"&gt;embedding&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;embedding&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'[1,0]'&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;distance&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;examples&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;distance&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;&amp;lt;=&amp;gt;&lt;/code&gt; operator calculates cosine distance.&lt;/p&gt;

&lt;p&gt;The results are:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; embedding | distance
-----------+----------
 [1,0]     | 0
 [0.8,0.6] | 0.2
 [0,1]     | 1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first vector has a distance of 0 because it points in the same direction as the query.&lt;/p&gt;

&lt;p&gt;The second vector has a distance of 0.2, while the third is orthogonal to the query and has a distance of 1.&lt;/p&gt;

&lt;p&gt;The important point is that &lt;strong&gt;pgvector doesn't understand the meaning of text&lt;/strong&gt;. It stores vectors and performs mathematical operations on them.&lt;/p&gt;

&lt;p&gt;The embedding model is responsible for producing vectors that represent semantic relationships.&lt;/p&gt;

&lt;h3&gt;
  
  
  Vector Indexes
&lt;/h3&gt;

&lt;p&gt;Without an index, PostgreSQL performs an exact nearest-neighbor search.&lt;/p&gt;

&lt;p&gt;For larger datasets, pgvector supports approximate nearest-neighbor indexes such as HNSW and IVFFlat.&lt;/p&gt;

&lt;p&gt;For example, we can create an HNSW index:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;examples_embedding_hnsw_idx&lt;/span&gt;
&lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;examples&lt;/span&gt;
&lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="n"&gt;hnsw&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;embedding&lt;/span&gt; &lt;span class="n"&gt;vector_cosine_ops&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The search query itself doesn't need to change.&lt;/p&gt;

&lt;p&gt;PostgreSQL can use the index to accelerate searches, although the query planner may choose a sequential scan for small datasets.&lt;/p&gt;

&lt;p&gt;Approximate indexes can improve search performance by trading off some retrieval accuracy.&lt;/p&gt;

&lt;h3&gt;
  
  
  Embedding Dimensions and Index Limitations
&lt;/h3&gt;

&lt;p&gt;One practical consideration when using pgvector is the number of dimensions in an embedding.&lt;/p&gt;

&lt;p&gt;pgvector supports storing vectors with up to 16,000 dimensions, but standard HNSW and IVFFlat indexes using the &lt;code&gt;vector&lt;/code&gt; type support up to 2,000 dimensions.&lt;/p&gt;

&lt;p&gt;This becomes relevant when using models such as OpenAI's &lt;code&gt;text-embedding-3-large&lt;/code&gt;, which produces 3,072-dimensional embeddings by default.&lt;/p&gt;

&lt;p&gt;One option is to use a shorter embedding supported by the model.&lt;/p&gt;

&lt;p&gt;OpenAI's &lt;code&gt;text-embedding-3&lt;/code&gt; models use &lt;strong&gt;Matryoshka Representation Learning (MRL)&lt;/strong&gt;, which allows shorter embeddings to retain useful semantic information.&lt;/p&gt;

&lt;p&gt;For example, we can request a 1,536-dimensional embedding from &lt;code&gt;text-embedding-3-large&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl https://api.openai.com/v1/embeddings &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$OPENAI_API_KEY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{
    "model": "text-embedding-3-large",
    "input": "I caught up with John over coffee yesterday.",
    "dimensions": 1536
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This reduces storage requirements and allows the embedding to fit within pgvector's standard vector index dimension limit.&lt;/p&gt;

&lt;p&gt;Another option is pgvector's &lt;code&gt;halfvec&lt;/code&gt; type, which supports indexing up to 4,000 dimensions using half-precision values.&lt;/p&gt;

&lt;p&gt;The right approach depends on retrieval quality, storage, precision, and performance requirements.&lt;/p&gt;

&lt;p&gt;For our example using 384-dimensional embeddings, the standard &lt;code&gt;vector&lt;/code&gt; type is sufficient.&lt;/p&gt;

&lt;h2&gt;
  
  
  Store and Search Embeddings with pgvector
&lt;/h2&gt;

&lt;p&gt;Now that we've covered embedding generation, cosine distance, and pgvector, let's bring everything together.&lt;/p&gt;

&lt;p&gt;We'll store the four memories with their embeddings in PostgreSQL and retrieve them using cosine distance.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;psycopg&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pgvector.psycopg&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;register_vector&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;sentence_transformers&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;SentenceTransformer&lt;/span&gt;

&lt;span class="n"&gt;DB_URL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;postgresql://user:password@localhost:5432/mydatabase&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="n"&gt;model&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;SentenceTransformer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sentence-transformers/all-MiniLM-L6-v2&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;texts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;I forgot my umbrella and got soaked in the rain.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;I lifted weights at the gym after work.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;I caught up with John over coffee yesterday.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;I stayed up late debugging my application.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="n"&gt;query&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Who did I meet recently?&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="n"&gt;DIMENSIONS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;384&lt;/span&gt;

&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;psycopg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;DB_URL&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CREATE EXTENSION IF NOT EXISTS vector&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;register_vector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;
        CREATE TABLE IF NOT EXISTS journal (
            id BIGSERIAL PRIMARY KEY,
            content TEXT NOT NULL,
            embedding VECTOR(&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;DIMENSIONS&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;) NOT NULL
        )
    &lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;embeddings&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;texts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;# Reset the demo data
&lt;/span&gt;    &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;TRUNCATE TABLE journal RESTART IDENTITY&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;embedding&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;zip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;texts&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;embeddings&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;
            INSERT INTO journal (content, embedding)
            VALUES (%s, %s)
            &lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;embedding&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;commit&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="n"&gt;query_embedding&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;
        SELECT
            content,
            embedding &amp;lt;=&amp;gt; %s AS distance
        FROM journal
        ORDER BY embedding &amp;lt;=&amp;gt; %s ASC
        LIMIT 4
        &lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query_embedding&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;query_embedding&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;fetchall&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Query: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;distance&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;distance&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; - &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;# Query: Who did I meet recently?
# 0.6097 - I caught up with John over coffee yesterday.
# 0.7981 - I forgot my umbrella and got soaked in the rain.
# 0.8151 - I lifted weights at the gym after work.
# 0.8454 - I stayed up late debugging my application.
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There are two key differences from the in-memory implementation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Embeddings are persisted&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;pgvector provides the &lt;code&gt;VECTOR(384)&lt;/code&gt; data type, allowing embeddings to be stored alongside their original text.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;register_vector(conn)&lt;/code&gt; integrates pgvector with psycopg so NumPy arrays can be passed directly as parameters.&lt;/p&gt;

&lt;p&gt;In a real application, embeddings would be generated when memories are created or updated, rather than on every query.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Similarity search moves into PostgreSQL&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Instead of loading every embedding into Python to calculate distances, we let PostgreSQL perform the search.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;&amp;lt;=&amp;gt;&lt;/code&gt; operator calculates cosine distance, and the query returns memories ordered by their distance from the query embedding.&lt;/p&gt;

&lt;p&gt;As shown in the output, the ranking matches our earlier Python example, with the memory about meeting John appearing first.&lt;/p&gt;

&lt;p&gt;For larger datasets, vector indexes can improve search performance without changing the core retrieval logic.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;We've explored how embeddings represent sentence meanings numerically, how cosine distance helps identify related text, and how to retrieve relevant sentences even when their wording differs.&lt;/p&gt;

&lt;p&gt;We started with a locally runnable embedding model, calculated distances in Python, and then introduced pgvector using simple two-dimensional vectors.&lt;/p&gt;

&lt;p&gt;Finally, we combined everything into a PostgreSQL-backed semantic search implementation.&lt;/p&gt;

&lt;p&gt;The same retrieval approach can be used in a Retrieval-Augmented Generation (RAG) system, where retrieved text is provided to an LLM as context for generating an answer.&lt;/p&gt;

&lt;p&gt;There are further considerations for production systems, including vector indexing, hybrid search, relevance thresholds, and retrieval evaluation.&lt;/p&gt;

&lt;p&gt;But the core mechanism is straightforward: &lt;strong&gt;generate embeddings, calculate distances, and retrieve the closest matches.&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>embedding</category>
      <category>pgvector</category>
    </item>
    <item>
      <title>Why I Chose Third-Party (Firebase) Authentication</title>
      <dc:creator>Joung Park</dc:creator>
      <pubDate>Fri, 09 Oct 2026 06:12:00 +0000</pubDate>
      <link>https://dev.to/joungpark/why-i-chose-third-party-firebase-authentication-43hn</link>
      <guid>https://dev.to/joungpark/why-i-chose-third-party-firebase-authentication-43hn</guid>
      <description>&lt;p&gt;Authentication is essential to Second-Memory, but it isn't its core business.&lt;/p&gt;

&lt;p&gt;The goal of Second-Memory is to help users capture their thoughts and retrieve meaningful information from them. Building an authentication system doesn't directly advance that goal.&lt;/p&gt;

&lt;p&gt;There's also a user-experience consideration. People already manage many online accounts. Asking them to create and remember another password adds friction, while social login lets them sign in with an account they already have.&lt;/p&gt;

&lt;h2&gt;
  
  
  Build vs. Buy
&lt;/h2&gt;

&lt;p&gt;I considered two approaches: building authentication in-house or using an existing service.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Consideration&lt;/th&gt;
&lt;th&gt;In-house authentication&lt;/th&gt;
&lt;th&gt;Firebase Authentication&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Initial development&lt;/td&gt;
&lt;td&gt;Build authentication flows and supporting infrastructure&lt;/td&gt;
&lt;td&gt;Integrate an existing service&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Security&lt;/td&gt;
&lt;td&gt;Implement and maintain security controls&lt;/td&gt;
&lt;td&gt;Firebase manages core authentication infrastructure; the application still has security responsibilities&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Maintenance&lt;/td&gt;
&lt;td&gt;Handle security updates and operational issues&lt;/td&gt;
&lt;td&gt;Google maintains the service&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;User experience&lt;/td&gt;
&lt;td&gt;Users may need another account and password&lt;/td&gt;
&lt;td&gt;Users can sign in with a supported existing identity&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Engineering cost&lt;/td&gt;
&lt;td&gt;More development and ongoing maintenance&lt;/td&gt;
&lt;td&gt;Less implementation and maintenance work&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For a project like Second-Memory, building authentication from scratch would mean taking on additional complexity without providing much differentiation.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Does In-House Authentication Involve?
&lt;/h2&gt;

&lt;p&gt;Building authentication is more than verifying a username and password. It means taking responsibility for several areas.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Security&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Store passwords using an appropriate password-hashing algorithm.&lt;/li&gt;
&lt;li&gt;Protect against brute-force attacks and credential stuffing.&lt;/li&gt;
&lt;li&gt;Implement secure session management, including expiration and revocation.&lt;/li&gt;
&lt;li&gt;Protect account recovery and verification flows against account takeover.&lt;/li&gt;
&lt;li&gt;Manage secrets, security logging, monitoring, and incident response.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;2. Account lifecycle&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Registration and email verification&lt;/li&gt;
&lt;li&gt;Login and logout&lt;/li&gt;
&lt;li&gt;Account recovery and password resets&lt;/li&gt;
&lt;li&gt;Account deletion and related data cleanup&lt;/li&gt;
&lt;li&gt;Session revocation and suspicious-login handling&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;3. Social login integration&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Supporting social login involves integrating with external identity providers and handling their authentication flows. Standards such as OpenID Connect (OIDC) and OAuth 2.0 are commonly involved, depending on the provider and flow.&lt;/p&gt;

&lt;p&gt;The application must also handle identity mapping, account linking where appropriate, and the relationship between an external identity and its own user records.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Ongoing operations&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Authentication requires continued attention after launch. Security vulnerabilities emerge, dependencies need updates, and authentication flows must be tested as the application evolves.&lt;/p&gt;

&lt;p&gt;The effort doesn't end when login starts working.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Decision
&lt;/h2&gt;

&lt;p&gt;I don't have specific authentication requirements that justify building and maintaining my own system.&lt;/p&gt;

&lt;p&gt;I'll use Firebase Authentication and make social login the preferred way to sign in. This avoids asking users to create another password and lets me rely on an established authentication service.&lt;/p&gt;

&lt;p&gt;There may be usage costs and some provider dependency, but building authentication in-house also has costs in development time, maintenance, and security responsibilities.&lt;/p&gt;

&lt;p&gt;For Second-Memory, Firebase Authentication is a practical choice because it meets my needs without requiring me to build and operate an authentication system myself.&lt;/p&gt;

&lt;p&gt;However, using a third-party authentication service doesn't eliminate application security responsibilities. Second-Memory still needs to validate identities correctly, enforce authorization in the backend, and ensure users can access only their own data.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Authentication Works in Second-Memory
&lt;/h2&gt;

&lt;p&gt;Second-Memory has web and mobile clients, plus backend services. Both clients use Firebase Authentication, while the backend verifies each request and maps the Firebase identity to an internal user record.&lt;/p&gt;

&lt;h3&gt;
  
  
  The authentication flow
&lt;/h3&gt;



&lt;pre data-lang="mermaid"&gt;&lt;code&gt;sequenceDiagram
    participant C as Web / Mobile Client
    participant F as Firebase Authentication
    participant A as Backend API
    participant D as Second-Memory Database

    C-&amp;gt;&amp;gt;F: Sign in
    F--&amp;gt;&amp;gt;C: Firebase ID token
    C-&amp;gt;&amp;gt;A: API request + Bearer token
    A-&amp;gt;&amp;gt;A: Verify token and extract uid
    A-&amp;gt;&amp;gt;D: Find user by firebaseUid
    D--&amp;gt;&amp;gt;A: Return internal user record
    A-&amp;gt;&amp;gt;A: Authorize request
    A--&amp;gt;&amp;gt;C: API response&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;The flow has six steps:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Sign in:&lt;/strong&gt; The client signs in with Firebase Authentication and obtains a Firebase ID token.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Call the API:&lt;/strong&gt; The client sends the token in the &lt;code&gt;Authorization: Bearer &amp;lt;token&amp;gt;&lt;/code&gt; header.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Verify the token:&lt;/strong&gt; The backend verifies the token using the Firebase Admin SDK. It must not trust a token simply because it can decode it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Extract the Firebase UID:&lt;/strong&gt; After verification, the backend reads the user's Firebase UID from the token.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Resolve the internal user:&lt;/strong&gt; The backend looks up the user record using &lt;code&gt;firebaseUid&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Authorize the request:&lt;/strong&gt; The backend uses the internal user ID to enforce access controls and scope data operations to that user.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Why maintain our own user table?
&lt;/h3&gt;

&lt;p&gt;Firebase provides authentication, but Second-Memory still needs its own application-level user record.&lt;/p&gt;

&lt;p&gt;For example:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;id&lt;/th&gt;
&lt;th&gt;firebaseUid&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;usr_123&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;firebase-uid-abc&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;usr_456&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;firebase-uid-xyz&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The &lt;code&gt;id&lt;/code&gt; is Second-Memory's internal user ID, while &lt;code&gt;firebaseUid&lt;/code&gt; links the record to Firebase Authentication. The &lt;code&gt;firebaseUid&lt;/code&gt; column should have a unique constraint.&lt;/p&gt;

&lt;p&gt;This separation has a few advantages:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Domain independence:&lt;/strong&gt; Memories and other application entities can reference Second-Memory's internal user ID.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Consistent authorization:&lt;/strong&gt; Backend services can use the same internal identity when checking data access.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Flexibility:&lt;/strong&gt; If the authentication provider changes in the future, the application's domain model doesn't need to use provider-specific identifiers everywhere.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The backend must still enforce authorization on every relevant request. A valid Firebase token proves the caller's authenticated identity; it does not automatically grant access to a particular memory or resource. The backend should derive identity from the verified token, not trust a user ID supplied by the client.&lt;/p&gt;

&lt;h3&gt;
  
  
  How this fits the client and backend stack
&lt;/h3&gt;

&lt;p&gt;The same authentication principle applies across the different parts of Second-Memory, although their implementations differ.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Next.js web app:&lt;/strong&gt; Configure Firebase for the web client, implement sign-in, and attach the ID token to API requests.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Expo mobile app:&lt;/strong&gt; Configure Firebase Authentication for React Native, handle session persistence, and attach the ID token to API requests.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Backend services:&lt;/strong&gt; Use the Firebase Admin SDK to verify ID tokens, resolve the internal user, and enforce authorization.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The important distinction is that the clients obtain tokens, but the backend is responsible for verifying them and deciding what the authenticated user is allowed to access.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;For Second-Memory, authentication is necessary infrastructure, not a feature I need to reinvent.&lt;/p&gt;

&lt;p&gt;Firebase Authentication reduces the amount of authentication code I need to build and maintain. Keeping an internal user record also lets Second-Memory separate external identity verification from application-level identity and authorization.&lt;/p&gt;

&lt;p&gt;The goal isn't to eliminate security responsibilities. It's to use an established service for authentication while keeping control of the application's own data and access rules.&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>authentication</category>
      <category>startup</category>
      <category>webdev</category>
    </item>
    <item>
      <title>From Monorepo to Turborepo: Managing Multiple Projects</title>
      <dc:creator>Joung Park</dc:creator>
      <pubDate>Fri, 09 Oct 2026 04:42:43 +0000</pubDate>
      <link>https://dev.to/joungpark/from-monorepo-to-turborepo-managing-multiple-projects-6e3</link>
      <guid>https://dev.to/joungpark/from-monorepo-to-turborepo-managing-multiple-projects-6e3</guid>
      <description>&lt;h2&gt;
  
  
  Why a Monorepo?
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;A monorepo is a single repository containing multiple distinct projects, with well-defined relationships. — &lt;a href="https://monorepo.tools" rel="noopener noreferrer"&gt;Monorepo Explained&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A monorepo makes sense when multiple projects have meaningful relationships:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Shared libraries and utilities&lt;/strong&gt; — reuse common code across projects.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Shared contracts&lt;/strong&gt; — keep API types consistent between producers and consumers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cross-project changes&lt;/strong&gt; — update related projects in a single change.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Shared tooling and rules&lt;/strong&gt; — maintain consistent development practices.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If projects have no meaningful relationships, separate repositories may be simpler.&lt;/p&gt;

&lt;p&gt;For Second-Memory, Web, Mobile, and Backend need to agree on API contracts. Sharing TypeScript API types helps keep those contracts consistent.&lt;/p&gt;

&lt;p&gt;Web uses React/Next.js, while Mobile uses React Native/Expo, so their UI implementations remain separate. Code is shared only where it genuinely works across both platforms.&lt;/p&gt;

&lt;p&gt;Keeping related projects together makes coordinating changes and managing shared contracts easier.&lt;/p&gt;

&lt;p&gt;How do we manage their packages and dependencies?&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Workspaces?
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;Workspaces are a set of features in the npm CLI that provide support for managing multiple packages from a local file system within a single top-level root package. — &lt;a href="https://docs.npmjs.com/cli/v8/using-npm/workspaces" rel="noopener noreferrer"&gt;npm Workspaces&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A monorepo can work without workspaces. Each project can have its own &lt;code&gt;package.json&lt;/code&gt; and manage dependencies independently.&lt;/p&gt;

&lt;p&gt;For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;second-memory/
├── apps/
│   ├── web/
│   └── mobile/
├── services/
│   ├── memory-service/
│   ├── ask-service/
│   └── embedding-worker/
└── packages/
    ├── api-types/
    └── ui-component/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Suppose both Web and Memory Service depend on &lt;code&gt;api-types&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Without workspaces, each project needs to manage its dependencies and local references separately. A project might reference the shared package using a local file dependency:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"dependencies"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"@second-memory/api-types"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"file:../../packages/api-types"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The relative path depends on the consuming project's location. Dependencies must be installed and managed in each project, and keeping local references and updates consistent requires additional care.&lt;/p&gt;

&lt;p&gt;With workspaces, the root configuration defines which directories contain packages.&lt;/p&gt;

&lt;p&gt;For example, with pnpm:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;pnpm-workspace.yaml&lt;/code&gt;&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;packages&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;apps/*"&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;services/*"&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;packages/*"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each package declares its own dependencies in its &lt;code&gt;package.json&lt;/code&gt;. For example, Web can depend on the shared API types package:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;apps/web/package.json&lt;/code&gt;&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"dependencies"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"@second-memory/api-types"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"workspace:*"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The shared package identifies itself in its own &lt;code&gt;package.json&lt;/code&gt;:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;packages/api-types/package.json&lt;/code&gt;&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"@second-memory/api-types"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"version"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1.0.0"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;workspace:*&lt;/code&gt; protocol explicitly tells pnpm to use the local workspace package.&lt;/p&gt;

&lt;p&gt;Dependencies can then be installed from the repository root:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pnpm &lt;span class="nb"&gt;install&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;pnpm recognises the workspace packages and links local dependencies, making it easier to manage relationships between projects.&lt;/p&gt;

&lt;h3&gt;
  
  
  Without Workspaces vs With Workspaces
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Area&lt;/th&gt;
&lt;th&gt;Without workspaces&lt;/th&gt;
&lt;th&gt;With workspaces&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Install dependencies&lt;/td&gt;
&lt;td&gt;Install separately in each project&lt;/td&gt;
&lt;td&gt;Install from the root&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reference shared packages&lt;/td&gt;
&lt;td&gt;Configure local file dependencies or links&lt;/td&gt;
&lt;td&gt;Declare dependencies between workspace packages&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Manage package relationships&lt;/td&gt;
&lt;td&gt;Maintain references across projects&lt;/td&gt;
&lt;td&gt;Define participating packages in the root configuration&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Develop shared packages&lt;/td&gt;
&lt;td&gt;Manage local references and updates carefully&lt;/td&gt;
&lt;td&gt;Work with linked local packages&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Run builds and tests&lt;/td&gt;
&lt;td&gt;Run scripts manually or maintain root scripts&lt;/td&gt;
&lt;td&gt;Still requires scripts or task orchestration&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A workspace reduces manual package and dependency management. It provides a way to define packages and their local dependencies within the monorepo.&lt;/p&gt;

&lt;p&gt;However, managing packages is only part of the problem. As the number of projects and dependencies grows, coordinating builds, tests, and other tasks becomes another challenge.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Task Orchestration?
&lt;/h2&gt;

&lt;p&gt;With only a few projects, running build and test commands manually may be enough. As the repository grows, two problems become more noticeable.&lt;/p&gt;

&lt;h3&gt;
  
  
  Problem 1: Running Only the Necessary Tasks
&lt;/h3&gt;

&lt;p&gt;The dependencies in Second-Memory look like this:&lt;br&gt;
&lt;/p&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;flowchart TD
    AT[api-types] --&amp;gt; W[apps/web]
    AT --&amp;gt; M[apps/mobile]
    AT --&amp;gt; MS[memory-service]
    AT --&amp;gt; AS[ask-service]

    UI[ui-component] --&amp;gt; W
    UI --&amp;gt; M

    EW[embedding-worker]&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;Suppose I change &lt;code&gt;packages/ui-component&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Web and Mobile depend on it, but Memory Service, Ask Service, and Embedding Worker do not.&lt;/p&gt;

&lt;p&gt;Ideally, I want to run checks for the affected projects without wasting time checking unrelated services.&lt;/p&gt;

&lt;p&gt;With workspaces alone, I can run individual commands using package filters:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pnpm &lt;span class="nt"&gt;--filter&lt;/span&gt; web &lt;span class="nb"&gt;test
&lt;/span&gt;pnpm &lt;span class="nt"&gt;--filter&lt;/span&gt; mobile &lt;span class="nb"&gt;test&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But I still need to know which projects depend on the changed package and decide which commands to run.&lt;/p&gt;

&lt;p&gt;As the dependency graph grows, maintaining this selection manually becomes more difficult.&lt;/p&gt;

&lt;p&gt;Task orchestration can automate the selection of relevant tasks based on package relationships, configured task dependencies, and changes.&lt;/p&gt;

&lt;h3&gt;
  
  
  Problem 2: Managing Dependencies Between Tasks
&lt;/h3&gt;

&lt;p&gt;Some tasks need to run before others.&lt;/p&gt;

&lt;p&gt;For example, if a service consumes a shared package's compiled output, that package needs to be built before the service.&lt;/p&gt;

&lt;p&gt;I could maintain a script to enforce the order:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"scripts"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"build:memory"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"pnpm --filter api-types build &amp;amp;&amp;amp; pnpm --filter memory-service build"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This works for a simple case, but the scripts become harder to maintain as more packages and task dependencies are added.&lt;/p&gt;

&lt;p&gt;Ideally, I want to define task relationships once and let a tool determine the correct execution order.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Task Orchestration Helps
&lt;/h2&gt;

&lt;p&gt;Task orchestration adds a layer above workspace package management. It coordinates tasks such as builds, tests, and linting across packages.&lt;/p&gt;

&lt;h3&gt;
  
  
  Task Graph
&lt;/h3&gt;

&lt;p&gt;A task graph describes dependencies between tasks and helps determine their execution order.&lt;/p&gt;

&lt;p&gt;For example, if &lt;code&gt;api-types&lt;/code&gt; must be built before its consumers:&lt;br&gt;
&lt;/p&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;flowchart TD
    A[Build api-types] --&amp;gt; B[Build memory-service]
    A --&amp;gt; C[Build ask-service]
    A --&amp;gt; D[Build web]
    A --&amp;gt; E[Build mobile]&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;The graph should reflect the actual build requirements. If consumers compile the shared package's source directly, a separate build step may not be necessary.&lt;/p&gt;

&lt;p&gt;Turborepo uses the workspace package dependency graph together with configured task dependencies to determine execution order. The package relationships provide the foundation; task configuration defines how tasks depend on one another.&lt;/p&gt;

&lt;h3&gt;
  
  
  Affected Tasks
&lt;/h3&gt;

&lt;p&gt;When a package changes, Turborepo can use Git change information and the dependency graph to select affected packages and their relevant tasks.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Change &lt;code&gt;api-types&lt;/code&gt;:&lt;/strong&gt; Web, Mobile, Memory Service, and Ask Service may be affected.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Change &lt;code&gt;ui-component&lt;/code&gt;:&lt;/strong&gt; Web and Mobile may be affected.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Change &lt;code&gt;embedding-worker&lt;/code&gt;:&lt;/strong&gt; Its checks can run independently of the other projects.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This reduces the need to maintain manual build and test sequences as the repository grows. The result depends on the configured task graph, available Git change information, and the tasks defined by each package.&lt;/p&gt;

&lt;h3&gt;
  
  
  Additional Benefits
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Technique&lt;/th&gt;
&lt;th&gt;Benefit&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Caching&lt;/td&gt;
&lt;td&gt;Reuse valid task results instead of repeating unchanged work&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Parallel execution&lt;/td&gt;
&lt;td&gt;Run independent tasks concurrently while respecting dependencies&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Remote caching&lt;/td&gt;
&lt;td&gt;Share cached results across developers and CI when configured&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;These capabilities can improve development and CI performance, although the primary goal is to manage task relationships and execution more reliably.&lt;/p&gt;

&lt;h2&gt;
  
  
  Comparing the Approaches
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Capability&lt;/th&gt;
&lt;th&gt;Workspaces alone&lt;/th&gt;
&lt;th&gt;Turborepo&lt;/th&gt;
&lt;th&gt;Nx&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Manage packages and local dependencies&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Uses a workspace package manager&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Run scripts across packages&lt;/td&gt;
&lt;td&gt;Yes, using scripts and filters&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Coordinate task dependencies&lt;/td&gt;
&lt;td&gt;Manual scripts or additional tooling&lt;/td&gt;
&lt;td&gt;Built-in task orchestration&lt;/td&gt;
&lt;td&gt;Built-in task orchestration&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Identify affected tasks&lt;/td&gt;
&lt;td&gt;Manual selection or custom tooling&lt;/td&gt;
&lt;td&gt;Supported&lt;/td&gt;
&lt;td&gt;Supported&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cache task results&lt;/td&gt;
&lt;td&gt;Requires additional tooling&lt;/td&gt;
&lt;td&gt;Built-in&lt;/td&gt;
&lt;td&gt;Built-in&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Run independent tasks in parallel&lt;/td&gt;
&lt;td&gt;Manual or script-based&lt;/td&gt;
&lt;td&gt;Built-in&lt;/td&gt;
&lt;td&gt;Built-in&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Broader project tooling and plugins&lt;/td&gt;
&lt;td&gt;Limited to package management&lt;/td&gt;
&lt;td&gt;Task-focused&lt;/td&gt;
&lt;td&gt;More extensive options&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Workspaces alone can be sufficient for a small repository with simple build and test requirements. Turborepo and Nx become useful when coordinating tasks across project dependencies requires more automation.&lt;/p&gt;

&lt;p&gt;Both provide task orchestration, affected-task capabilities, caching, and parallel execution. Nx offers a broader set of project tooling and plugins, while Turborepo focuses more directly on task orchestration.&lt;/p&gt;

&lt;p&gt;For Second-Memory, I wanted the task orchestration capabilities without needing a broader project-management system.&lt;/p&gt;

</description>
      <category>monorepo</category>
      <category>turborepo</category>
      <category>workspaces</category>
      <category>pnpm</category>
    </item>
    <item>
      <title>Monorepo or Polyrepo?</title>
      <dc:creator>Joung Park</dc:creator>
      <pubDate>Wed, 07 Oct 2026 05:52:16 +0000</pubDate>
      <link>https://dev.to/joungpark/monorepo-or-polyrepo-1gib</link>
      <guid>https://dev.to/joungpark/monorepo-or-polyrepo-1gib</guid>
      <description>&lt;p&gt;After deciding to use microservices, the next question was:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How should I organise the codebase?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I chose a &lt;strong&gt;monorepo&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Second-Memory has several applications and services:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;apps/
  web/
  mobile/

services/
  memory-service/
  ask-service/
  embedding-worker/

packages/
  api-types/
  ui-components/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;They are independently deployable, but they are all part of the same product.&lt;/p&gt;

&lt;h2&gt;
  
  
  Monorepo vs Polyrepo
&lt;/h2&gt;

&lt;p&gt;There is no universally better choice.&lt;/p&gt;

&lt;p&gt;The trade-offs become clearer when looking at common development tasks:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Area&lt;/th&gt;
&lt;th&gt;Monorepo&lt;/th&gt;
&lt;th&gt;Polyrepo&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Share code&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Easy&lt;/strong&gt; — reusable code can live in shared packages&lt;/td&gt;
&lt;td&gt;Harder — publish packages or duplicate code&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Share types&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Easy&lt;/strong&gt; — TypeScript types can be shared directly&lt;/td&gt;
&lt;td&gt;Harder — types need separate versioning&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Cross-project changes&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Easy&lt;/strong&gt; — related changes can be made in one PR&lt;/td&gt;
&lt;td&gt;Harder — changes may require multiple PRs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Dependency management&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Easy&lt;/strong&gt; — dependencies can be managed consistently&lt;/td&gt;
&lt;td&gt;More complex — each repo manages its own dependencies&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Local development&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Easy&lt;/strong&gt; — one repository contains the whole system&lt;/td&gt;
&lt;td&gt;More setup — multiple repositories need to be configured&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;CI/CD&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;More complex — need to detect affected projects&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Easy&lt;/strong&gt; — each repository naturally has its own pipeline&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Service isolation&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Harder — boundaries need to be enforced within the repository&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Easy&lt;/strong&gt; — repositories provide natural isolation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Access control&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Harder — repository access generally covers the whole codebase&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Easy&lt;/strong&gt; — permissions can be managed per repository&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Independent releases&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Possible — CI/CD can deploy projects independently&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Easy&lt;/strong&gt; — each repository has its own release lifecycle&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Neither approach is inherently better.&lt;/p&gt;

&lt;p&gt;For Second-Memory, I valued &lt;strong&gt;easy development across the whole system&lt;/strong&gt; more than repository-level isolation.&lt;/p&gt;

&lt;h2&gt;
  
  
  When to Choose Which
&lt;/h2&gt;

&lt;p&gt;A monorepo can be a good fit when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Small team or solo developer&lt;/strong&gt; — one repository is easier to manage than several.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Projects share contracts or code&lt;/strong&gt; — changes can be made and tested together.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cross-project changes are common&lt;/strong&gt; — one PR can update multiple applications or services.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The whole system is closely related&lt;/strong&gt; — one repository makes it easier to understand and work across the product.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Polyrepo can be a better fit when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Large or independent teams&lt;/strong&gt; need clear ownership boundaries.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Strong repository isolation&lt;/strong&gt; is important.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Projects have different release cycles&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Access control&lt;/strong&gt; needs to differ between projects.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For Second-Memory, being a &lt;strong&gt;sole developer&lt;/strong&gt; was one of the main reasons I chose a monorepo.&lt;/p&gt;

&lt;p&gt;Managing several repositories would add overhead without giving me much benefit. I didn't need repository-level ownership boundaries between teams.&lt;/p&gt;

&lt;p&gt;Instead, I wanted to make it easy to work across the web app, mobile app, backend services, and shared contracts.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Should Be Shared?
&lt;/h2&gt;

&lt;p&gt;A monorepo makes sharing easy.&lt;/p&gt;

&lt;p&gt;But &lt;strong&gt;easy to share doesn't mean everything should be shared&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;I wanted to keep the shared surface area small.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Shared Part&lt;/th&gt;
&lt;th&gt;Used By&lt;/th&gt;
&lt;th&gt;Why Share It?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;API types&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Web, Mobile, Backend&lt;/td&gt;
&lt;td&gt;Keep API contracts consistent&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Web-specific code&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Web&lt;/td&gt;
&lt;td&gt;Keep it inside &lt;code&gt;apps/web&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Mobile-specific code&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Mobile&lt;/td&gt;
&lt;td&gt;Keep it inside &lt;code&gt;apps/mobile&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Service domain logic&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Individual service&lt;/td&gt;
&lt;td&gt;Keep ownership within the service&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Service implementation&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Individual service&lt;/td&gt;
&lt;td&gt;Preserve service boundaries&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;So instead of creating shared packages for everything, I only create a package when there is a genuine reason to share something.&lt;/p&gt;

&lt;p&gt;For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;apps/
  web/
  mobile/
services/
  memory-service/
  ask-service/
  embedding-worker/
packages/
  api-types/
  ui-components/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The API types are shared because they represent a contract between different parts of the system.&lt;/p&gt;

&lt;p&gt;But a Web-only component stays in &lt;code&gt;apps/web&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;A Mobile-only utility stays in &lt;code&gt;apps/mobile&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;And Memory Service domain logic stays inside &lt;code&gt;memory-service&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Share the contract, not the ownership
&lt;/h3&gt;

&lt;p&gt;The fact that services live in the same repository doesn't mean they should share their internal implementation.&lt;/p&gt;

&lt;p&gt;The API contract can be shared:&lt;br&gt;
&lt;/p&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;flowchart TB
    T[packages/api-types]

    W[Web] --&amp;gt; T
    M[Mobile] --&amp;gt; T
    B[Backend Services] --&amp;gt; T&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;while the services remain independently owned:&lt;br&gt;
&lt;/p&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;flowchart TB
    MS[Memory Service]
    AS[Ask Service]

    MS --&amp;gt; MD[Memory Domain]
    AS --&amp;gt; AD[AI Interaction]

    MD --&amp;gt; DB1[(Memory DB)]
    AD --&amp;gt; DB2[(Ask Data)]&lt;/code&gt;&lt;/pre&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Share contracts where it makes sense, but don't share service ownership.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Keeping the Shared Surface Small
&lt;/h2&gt;

&lt;p&gt;There is another practical reason for limiting shared code.&lt;/p&gt;

&lt;p&gt;The more projects depend on a shared package, the larger the potential impact of a change.&lt;/p&gt;

&lt;p&gt;For example:&lt;br&gt;
&lt;/p&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;flowchart TB
    S[packages/shared]
    S --&amp;gt; W[Web]
    S --&amp;gt; M[Mobile]
    S --&amp;gt; ME[Memory]
    S --&amp;gt; A[Ask]&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;A change to &lt;code&gt;shared&lt;/code&gt; may require all projects to be tested.&lt;/p&gt;

&lt;p&gt;But a change here:&lt;br&gt;
&lt;/p&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;flowchart LR
    C[Change in apps/web] --&amp;gt; W[Web]
    W --&amp;gt; CI[Web CI/CD]&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;only affects Web.&lt;/p&gt;

&lt;p&gt;This has implications for both &lt;strong&gt;CI/CD&lt;/strong&gt; and &lt;strong&gt;application dependencies&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;I don't want a large &lt;code&gt;shared&lt;/code&gt; package that contains unrelated functionality used by almost every project.&lt;/p&gt;

&lt;p&gt;Instead, I prefer small, focused shared packages with clear reasons for existing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The CI/CD Trade-off
&lt;/h2&gt;

&lt;p&gt;CI/CD is one area where polyrepo can be simpler.&lt;/p&gt;

&lt;p&gt;With polyrepo, a change to &lt;code&gt;memory-service&lt;/code&gt; naturally belongs to the &lt;code&gt;memory-service&lt;/code&gt; repository.&lt;br&gt;
&lt;/p&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;flowchart TB
    R[memory-service repo]
    R --&amp;gt; CI[CI/CD]
    CI --&amp;gt; T[Test]
    CI --&amp;gt; B[Build]
    CI --&amp;gt; D[Deploy]&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;In a monorepo, multiple deployable units live in the same repository.&lt;/p&gt;

&lt;p&gt;A change to:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;services/memory-service/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;shouldn't necessarily rebuild and deploy everything.&lt;/p&gt;

&lt;p&gt;The pipeline needs to understand:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;what changed&lt;/li&gt;
&lt;li&gt;what depends on it&lt;/li&gt;
&lt;li&gt;what needs to be tested&lt;/li&gt;
&lt;li&gt;what needs to be built&lt;/li&gt;
&lt;li&gt;what needs to be deployed&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This becomes particularly important when shared packages are involved.&lt;/p&gt;

&lt;p&gt;A change to &lt;code&gt;ui-components&lt;/code&gt; may affect only web and mobile projects, while a change isolated to &lt;code&gt;services/embedding-worker/&lt;/code&gt; should ideally affect only the relevant pipeline.&lt;/p&gt;

&lt;p&gt;The repository structure itself is simple, but the build and deployment system needs to become more intelligent as the project grows.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Monorepo for Second-Memory?
&lt;/h2&gt;

&lt;p&gt;The main benefits for me were:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Easy&lt;/strong&gt; sharing of API types&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Easy&lt;/strong&gt; cross-project changes&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Easy&lt;/strong&gt; local development&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Easy&lt;/strong&gt; dependency management&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Easy&lt;/strong&gt; visibility of the whole product&lt;/li&gt;
&lt;li&gt;Less overhead for a &lt;strong&gt;solo developer&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The trade-offs were:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;More complex&lt;/strong&gt; CI/CD&lt;/li&gt;
&lt;li&gt;More tooling needed as the repository grows&lt;/li&gt;
&lt;li&gt;Less repository-level isolation&lt;/li&gt;
&lt;li&gt;The need to carefully control shared dependencies&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For Second-Memory, I was willing to accept these trade-offs.&lt;/p&gt;

&lt;p&gt;The goal wasn't to share as much code as possible.&lt;/p&gt;

&lt;p&gt;The goal was to get the convenience of a monorepo while keeping &lt;strong&gt;ownership and dependencies clear&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;That led to the next question:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do I manage builds, dependencies, task execution, and caching across all these projects?&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>microservices</category>
      <category>monorepo</category>
      <category>polyrepo</category>
    </item>
    <item>
      <title>Microservices or Monolith?</title>
      <dc:creator>Joung Park</dc:creator>
      <pubDate>Wed, 07 Oct 2026 04:18:28 +0000</pubDate>
      <link>https://dev.to/joungpark/why-microservices-instead-of-a-monolith-3ld3</link>
      <guid>https://dev.to/joungpark/why-microservices-instead-of-a-monolith-3ld3</guid>
      <description>&lt;p&gt;The initial architecture had three main backend responsibilities:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Memory Service&lt;/li&gt;
&lt;li&gt;Ask Service&lt;/li&gt;
&lt;li&gt;Embedding Worker&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;But there was another architectural decision behind this:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why make them separate services instead of building one backend?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A monolith would have been simpler.&lt;/p&gt;

&lt;p&gt;For a small application, it would probably have been enough.&lt;/p&gt;

&lt;p&gt;I still chose a microservice architecture for Second-Memory for three main reasons.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Clear boundaries
&lt;/h2&gt;

&lt;p&gt;Memory and Ask have different responsibilities.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;Memory Service&lt;/strong&gt; owns the memory domain:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;create and manage memories&lt;/li&gt;
&lt;li&gt;persist memory data&lt;/li&gt;
&lt;li&gt;generate/retrieve relevant memories&lt;/li&gt;
&lt;li&gt;manage embeddings&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The &lt;strong&gt;Ask Service&lt;/strong&gt; owns the AI interaction:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;receive questions&lt;/li&gt;
&lt;li&gt;retrieve relevant context&lt;/li&gt;
&lt;li&gt;construct prompts&lt;/li&gt;
&lt;li&gt;call the LLM&lt;/li&gt;
&lt;li&gt;return the answer&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;They work together, but they represent different responsibilities.&lt;/p&gt;

&lt;p&gt;I wanted those boundaries to exist at the service level rather than only as modules inside one application.&lt;br&gt;
&lt;/p&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;flowchart LR
    A[API Gateway] --&amp;gt; B[Memory Service]
    A --&amp;gt; C[Ask Service]
    B -.-&amp;gt; D[Memory + Vector]
    C -.-&amp;gt; E[LLM]&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;The Embedding Worker is separated for a different reason.&lt;/p&gt;

&lt;p&gt;Embedding generation is asynchronous work and doesn't need to be part of the request that creates a memory.&lt;br&gt;
&lt;/p&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;flowchart LR
    A[Memory Service] --&amp;gt; B[Outbox]
    B --&amp;gt; C[Queue]
    C --&amp;gt; D[Embedding Worker]&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;The boundaries therefore follow the responsibilities and execution models of the system.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. A system for learning AI engineering
&lt;/h2&gt;

&lt;p&gt;Second-Memory is also a project for exploring how AI systems are built.&lt;/p&gt;

&lt;p&gt;I didn't want the architecture to stop at:&lt;br&gt;
&lt;/p&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;flowchart LR
    A[Application] --&amp;gt; B[LLM API]&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;The system gave me an opportunity to work with several patterns that are common in production AI systems:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;asynchronous processing&lt;/li&gt;
&lt;li&gt;queues&lt;/li&gt;
&lt;li&gt;workers&lt;/li&gt;
&lt;li&gt;embeddings&lt;/li&gt;
&lt;li&gt;vector search&lt;/li&gt;
&lt;li&gt;retrieval&lt;/li&gt;
&lt;li&gt;service-to-service communication&lt;/li&gt;
&lt;li&gt;LLM integration&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Using separate services makes these boundaries explicit and gives me a way to experiment with each part independently.&lt;/p&gt;

&lt;p&gt;The goal isn't to use microservices simply because they are popular.&lt;/p&gt;

&lt;p&gt;The architecture should help me learn and validate how these pieces work together as a real system.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Production readiness and scaling
&lt;/h2&gt;

&lt;p&gt;I also wanted to design Second-Memory as something that could eventually grow into a production system.&lt;/p&gt;

&lt;p&gt;The different components have different scaling characteristics.&lt;/p&gt;

&lt;p&gt;If memory creation increases, I may need more Memory Service instances.&lt;/p&gt;

&lt;p&gt;If embedding jobs increase, I can scale the workers independently.&lt;/p&gt;

&lt;p&gt;If AI requests become the bottleneck, the Ask Service can scale independently.&lt;/p&gt;

&lt;p&gt;This doesn't mean I need to scale everything independently today.&lt;/p&gt;

&lt;p&gt;It means the architecture doesn't force unrelated workloads into the same deployment unit.&lt;/p&gt;

&lt;h2&gt;
  
  
  What about a monolith?
&lt;/h2&gt;

&lt;p&gt;A monolith would still be a reasonable choice for Second-Memory.&lt;/p&gt;

&lt;p&gt;It would have:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;less infrastructure&lt;/li&gt;
&lt;li&gt;simpler deployment&lt;/li&gt;
&lt;li&gt;simpler local development&lt;/li&gt;
&lt;li&gt;fewer service-to-service calls&lt;/li&gt;
&lt;li&gt;less operational overhead&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So why accept the additional complexity?&lt;/p&gt;

&lt;p&gt;Because my goal wasn't only to build the simplest V1.&lt;/p&gt;

&lt;p&gt;I wanted to build a system with &lt;strong&gt;clear boundaries that could evolve toward production scale&lt;/strong&gt;, while giving me a practical environment to explore AI system architecture.&lt;/p&gt;

&lt;p&gt;The trade-off was intentional.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;I chose microservices not because a monolith couldn't work, but because the boundaries, asynchronous workloads, learning goals, and potential scaling characteristics made the additional complexity worthwhile.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

</description>
      <category>architecture</category>
      <category>microservices</category>
      <category>monolith</category>
    </item>
    <item>
      <title>Why the Outbox Pattern, Queue, and Embedding Worker?</title>
      <dc:creator>Joung Park</dc:creator>
      <pubDate>Wed, 07 Oct 2026 03:47:01 +0000</pubDate>
      <link>https://dev.to/joungpark/why-the-outbox-pattern-queue-and-embedding-worker-2pb5</link>
      <guid>https://dev.to/joungpark/why-the-outbox-pattern-queue-and-embedding-worker-2pb5</guid>
      <description>&lt;p&gt;The previous post covered why I chose pgvector for semantic search.&lt;/p&gt;

&lt;p&gt;But there was another question:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When should a memory be embedded?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;At first, it might seem simple:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Create memory
     ▼
Generate embedding
     ▼
Save embedding
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But this makes memory creation dependent on the embedding process.&lt;/p&gt;

&lt;p&gt;If the embedding provider is slow or unavailable, creating a memory could also fail or become slow.&lt;/p&gt;

&lt;p&gt;I wanted to separate these two operations.&lt;/p&gt;

&lt;p&gt;The architecture became:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Memory Service
      ▼
 PostgreSQL
      ▼
 Outbox Table
      ▼
 Queue / Job Runner
      ▼
 Embedding Worker
      ▼
 pgvector
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  1. The Outbox Pattern
&lt;/h2&gt;

&lt;p&gt;When a user creates a memory, the Memory Service writes the memory and an outbox event in the same database transaction.&lt;/p&gt;

&lt;p&gt;For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Transaction
┌─────────────────────────────┐
│ INSERT memory               │
│ INSERT outbox event         │
└─────────────────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The important part is that they succeed or fail together.&lt;/p&gt;

&lt;p&gt;This avoids a common problem with distributed systems:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Memory saved
     ▼
Embedding event lost
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The outbox gives me a durable record of the work that needs to happen.&lt;/p&gt;

&lt;p&gt;The outbox is not the queue itself.&lt;/p&gt;

&lt;p&gt;It is a reliable bridge between the database transaction and asynchronous processing.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. The Queue / Job Runner
&lt;/h2&gt;

&lt;p&gt;A separate process reads pending outbox events and puts jobs onto a queue.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Outbox
   ▼
Queue
   ▼
Embedding Worker
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This gives the embedding process some useful properties:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;asynchronous processing&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;retries&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;independent scaling&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;failure isolation&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;no need to block the memory API&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The user can save a memory without waiting for the embedding provider.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. The Embedding Worker
&lt;/h2&gt;

&lt;p&gt;The embedding worker has one main responsibility:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;turn memory text into an embedding and store it in pgvector.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;flowchart TD
    A[Get Memory] --&amp;gt; B[Generate embedding]
    B --&amp;gt; C[Store Vector]&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;This keeps embedding-specific logic out of the Memory Service's synchronous request path.&lt;/p&gt;

&lt;p&gt;It also gives me a place to evolve the embedding pipeline later.&lt;/p&gt;

&lt;p&gt;For example, I could change:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;embedding models&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;chunking strategy&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;retry behaviour&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;batch processing&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;embedding dimensions&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;without changing the API used to create a memory.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Why asynchronous?
&lt;/h2&gt;

&lt;p&gt;There is an important consequence of this design:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A newly created memory may not be immediately searchable.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;There can be a small delay between:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Memory created
     │ asynchronous processing
     ▼
Embedding created
     ▼
Memory available for semantic search
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I accepted this trade-off.&lt;/p&gt;

&lt;p&gt;For Second-Memory, immediate persistence is more important than making the embedding operation part of the user's request.&lt;/p&gt;

&lt;p&gt;This is essentially &lt;strong&gt;eventual consistency&lt;/strong&gt; between the memory record and its vector representation.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Failure becomes easier to handle
&lt;/h2&gt;

&lt;p&gt;The asynchronous design also changes how failures work.&lt;/p&gt;

&lt;p&gt;If the embedding provider temporarily fails:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Memory
  ▼
Outbox
  ▼
Queue
  ▼
Embedding Worker - X -&amp;gt; Retry
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The memory itself has already been safely stored.&lt;/p&gt;

&lt;p&gt;The embedding job can be retried without asking the user to submit the memory again.&lt;/p&gt;

&lt;p&gt;That separation was important to me.&lt;/p&gt;

&lt;h2&gt;
  
  
  The decision
&lt;/h2&gt;

&lt;p&gt;The final flow became:&lt;br&gt;
&lt;/p&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;graph TD
    CM[Create Memory] --&amp;gt; PG

    subgraph PG [PostgreSQL]
            M[Memory]
            OE[Outbox Event]
    end

    OE --&amp;gt; Q[Queue]
    Q --&amp;gt; EW[Embedding Worker]
    EW --&amp;gt; PV[(pgvector in PostgreSQL)]&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;Each part has a different responsibility:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Component&lt;/th&gt;
&lt;th&gt;Responsibility&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Memory Service&lt;/td&gt;
&lt;td&gt;Store the memory&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Outbox&lt;/td&gt;
&lt;td&gt;Reliably record the event&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Queue&lt;/td&gt;
&lt;td&gt;Deliver asynchronous work&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Embedding Worker&lt;/td&gt;
&lt;td&gt;Generate embeddings&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;pgvector&lt;/td&gt;
&lt;td&gt;Store and search vectors&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;This added some complexity compared with simply generating the embedding inside the API request.&lt;/p&gt;

&lt;p&gt;But it gave me something more important:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;memory creation is no longer tightly coupled to the availability of the embedding pipeline.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That was the trade-off I wanted.&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>backend</category>
      <category>database</category>
      <category>systemdesign</category>
    </item>
    <item>
      <title>Vector database</title>
      <dc:creator>Joung Park</dc:creator>
      <pubDate>Wed, 07 Oct 2026 03:34:17 +0000</pubDate>
      <link>https://dev.to/joungpark/-why-pgvector-2deb</link>
      <guid>https://dev.to/joungpark/-why-pgvector-2deb</guid>
      <description>&lt;p&gt;Second-Memory stores things a user writes over time.&lt;/p&gt;

&lt;p&gt;If a user later asks:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;“What did I write about performance problems?”&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A normal keyword search isn't always enough.&lt;/p&gt;

&lt;p&gt;The memory might say:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;“The database becomes slow when thousands of users query it at the same time.”&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;There may be no exact keyword match between the question and the memory.&lt;/p&gt;

&lt;p&gt;This is where &lt;strong&gt;semantic search&lt;/strong&gt; becomes useful.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. From text to vectors
&lt;/h2&gt;

&lt;p&gt;I can convert a piece of text into an &lt;strong&gt;embedding&lt;/strong&gt; — a vector representation of its meaning.&lt;/p&gt;

&lt;p&gt;For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;"The database becomes slow when thousands of users query it at the same time."
         ↓
[Embedding model]
         ↓
[0.021, -0.183, ...]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The actual vector contains many dimensions, so it isn't meaningful to look at the individual numbers.&lt;/p&gt;

&lt;p&gt;What matters is the relationship between vectors.&lt;/p&gt;

&lt;p&gt;Texts with similar meanings tend to have vectors that are closer together.&lt;/p&gt;

&lt;p&gt;So when a user asks a question, I can:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Question&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Embedding&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Vector similarity search&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Relevant memories&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This gives Second-Memory a way to retrieve memories based on &lt;strong&gt;meaning&lt;/strong&gt;, rather than just matching words.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Why pgvector?
&lt;/h2&gt;

&lt;p&gt;Once I decided to use embeddings, I needed somewhere to store and search them.&lt;/p&gt;

&lt;p&gt;I considered:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;pgvector&lt;/th&gt;
&lt;th&gt;Pinecone&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Vector search&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Relational data&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Existing PostgreSQL&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Separate infrastructure&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Operational complexity&lt;/td&gt;
&lt;td&gt;Lower&lt;/td&gt;
&lt;td&gt;Higher&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Good fit for V1&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;I chose &lt;strong&gt;pgvector&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The main reason wasn't that pgvector is necessarily better than Pinecone.&lt;/p&gt;

&lt;p&gt;It was that Second-Memory already had a natural place for the vectors:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;the Memory Service's database.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;With pgvector, I could keep the memory and its embedding together.&lt;br&gt;
&lt;/p&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;erDiagram
    "Memory Service" ||--|| "PostgreSQL + pgvector" : utilizes
    "PostgreSQL + pgvector" ||--|{ Memory : contains

    Memory {
        uuid user_id
        text content
        vector embedding
    }&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;That kept the architecture simple.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Why not introduce a vector database?
&lt;/h2&gt;

&lt;p&gt;A dedicated vector database could make sense at larger scale.&lt;/p&gt;

&lt;p&gt;But introducing one also creates another system to operate and another boundary to manage.&lt;/p&gt;

&lt;p&gt;For V1, I didn't see enough benefit to justify that complexity.&lt;/p&gt;

&lt;p&gt;The Memory Service could own:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;memory data&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;embeddings&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;vector search&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;all within the same data boundary.&lt;/p&gt;

&lt;p&gt;That also reinforced one of the architectural principles from the previous post:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The service that owns the data should own access to it.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The Ask Service doesn't need to know whether semantic search is implemented with pgvector, Pinecone, or something else.&lt;/p&gt;

&lt;p&gt;It simply asks the Memory Service for relevant memories.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. What this gives me
&lt;/h2&gt;

&lt;p&gt;With pgvector, the basic retrieval flow becomes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;User question
      ↓
Generate query embedding
      ↓
Memory Service
      ↓
pgvector similarity search
      ↓
Relevant memories
      ↓
Ask Service
      ↓
     LLM
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the first important piece of the AI architecture.&lt;/p&gt;

&lt;p&gt;The LLM doesn't need to know everything the user has ever written.&lt;/p&gt;

&lt;p&gt;Instead, the system retrieves the memories that are most relevant to the current question and uses those as context.&lt;/p&gt;

&lt;h2&gt;
  
  
  The decision
&lt;/h2&gt;

&lt;p&gt;For Second-Memory V1, I chose:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;PostgreSQL + pgvector&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;because it gave me semantic search without introducing another database.&lt;/p&gt;

&lt;p&gt;It was a pragmatic choice:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;vector search&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;relational data&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;one data boundary&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;less infrastructure&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;simpler development&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>pgvector</category>
      <category>rag</category>
      <category>vectordatabase</category>
      <category>ai</category>
    </item>
    <item>
      <title>The First Architecture Draft</title>
      <dc:creator>Joung Park</dc:creator>
      <pubDate>Wed, 07 Oct 2026 03:24:36 +0000</pubDate>
      <link>https://dev.to/joungpark/the-first-architecture-draft-2ek9</link>
      <guid>https://dev.to/joungpark/the-first-architecture-draft-2ek9</guid>
      <description>&lt;p&gt;I had the product definition, user stories, and backlog.&lt;/p&gt;

&lt;p&gt;Now it was time to design the system.&lt;/p&gt;

&lt;p&gt;I started with the main responsibilities of Second-Memory and came up with my first architecture draft:&lt;br&gt;
&lt;/p&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;flowchart 
    W[Web Client] --&amp;gt; G[API Gateway / BFF]
    M[Mobile Client] --&amp;gt; G

    G --&amp;gt; A[Auth]
    G --&amp;gt; MS[Memory Service]
    G --&amp;gt; AS[Ask Service]

    MS --&amp;gt; D[(Database)]
    MS --&amp;gt; V[(Vector DB)]

    AS --&amp;gt; L[LLM Provider]

    AS --&amp;gt; MS&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;The diagram itself is fairly simple, but there were several design principles behind it.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. BFF for the clients
&lt;/h2&gt;

&lt;p&gt;Both the web and mobile clients communicate through the API Gateway / BFF.&lt;/p&gt;

&lt;p&gt;I chose the &lt;strong&gt;Backend for Frontend (BFF)&lt;/strong&gt; pattern so that the clients don't need to communicate directly with the internal services.&lt;/p&gt;

&lt;p&gt;The BFF provides a client-facing API while hiding the internal architecture.&lt;/p&gt;

&lt;p&gt;It also gives me a place to handle things such as authentication, routing, and client-specific requirements.&lt;/p&gt;

&lt;p&gt;This means the clients don't need to know whether a request is handled by the Memory Service, Ask Service, or something else.&lt;/p&gt;

&lt;p&gt;The internal architecture can evolve without making the clients aware of every change.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Each service owns its data
&lt;/h2&gt;

&lt;p&gt;One principle I wanted to follow was:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Don't share databases between services.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A service owns the data that belongs to its responsibility.&lt;/p&gt;

&lt;p&gt;In this architecture, the Memory Service owns both the database and vector database.&lt;/p&gt;

&lt;p&gt;If another service needs memory data, it doesn't connect directly to either database.&lt;/p&gt;

&lt;p&gt;Instead, it calls the Memory Service through an internal API.&lt;/p&gt;

&lt;p&gt;For example:&lt;br&gt;
&lt;/p&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;flowchart 
    AS[Ask Service] --&amp;gt;|Internal API| MS
    MS[Memory Service] --&amp;gt; D[(Database)]
    MS --&amp;gt; V[(Vector DB)]&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;This keeps the data ownership clear and prevents other services from becoming coupled to the Memory Service's storage implementation.&lt;/p&gt;

&lt;p&gt;If the storage implementation changes later, the other service shouldn't need to know.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Separation of concerns
&lt;/h2&gt;

&lt;p&gt;Each component has a specific responsibility.&lt;/p&gt;

&lt;p&gt;The BFF deals with client-facing communication.&lt;/p&gt;

&lt;p&gt;The Memory Service deals with memory.&lt;/p&gt;

&lt;p&gt;The Ask Service deals with the AI interaction.&lt;/p&gt;

&lt;p&gt;The LLM generates the response.&lt;/p&gt;

&lt;p&gt;The databases provide persistence.&lt;/p&gt;

&lt;p&gt;This separation makes the architecture easier to reason about.&lt;/p&gt;

&lt;p&gt;It also means that AI-specific logic doesn't have to spread throughout the rest of the application.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. DDD and bounded contexts
&lt;/h2&gt;

&lt;p&gt;The separation between Memory Service and Ask Service wasn't just about splitting the code into smaller services.&lt;/p&gt;

&lt;p&gt;I was thinking about the different &lt;strong&gt;domains and responsibilities&lt;/strong&gt; involved.&lt;/p&gt;

&lt;p&gt;The Memory Service represents the memory domain:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;creating memories&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;retrieving memories&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;searching memories&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;managing memory data&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The Ask Service represents the AI interaction:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;receiving a question&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;deciding what information is needed&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;requesting relevant memories&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;constructing context&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;calling the LLM&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;generating the answer&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;They are closely related, but they are not the same responsibility.&lt;/p&gt;

&lt;p&gt;This is where the idea of &lt;strong&gt;bounded contexts&lt;/strong&gt; from Domain-Driven Design helped me think about the boundaries.&lt;/p&gt;

&lt;p&gt;The Ask Service uses the Memory Service, but it doesn't become responsible for managing memory itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Encapsulation and API contracts
&lt;/h2&gt;

&lt;p&gt;The service boundary also creates an important form of encapsulation.&lt;/p&gt;

&lt;p&gt;The Ask Service doesn't need to know how the Memory Service stores or searches memories.&lt;/p&gt;

&lt;p&gt;It only needs to know what the Memory Service provides through its API.&lt;/p&gt;

&lt;p&gt;For example, the Ask Service might ask for relevant memories, without knowing whether the Memory Service uses PostgreSQL, pgvector, a different vector store, or some other implementation in the future.&lt;/p&gt;

&lt;p&gt;The API becomes the contract between the services.&lt;/p&gt;

&lt;p&gt;This keeps implementation details inside the service that owns them.&lt;/p&gt;




&lt;p&gt;These principles gave me the initial structure of Second-Memory.&lt;/p&gt;

&lt;p&gt;It wasn't a complicated architecture, and that was intentional.&lt;/p&gt;

&lt;p&gt;I wanted clear boundaries without creating unnecessary complexity.&lt;/p&gt;

&lt;p&gt;With the boundaries in place, I could start looking at the individual technical decisions.&lt;/p&gt;

</description>
      <category>api</category>
      <category>architecture</category>
      <category>backend</category>
      <category>systemdesign</category>
    </item>
    <item>
      <title>Putting on the Product Owner Hat</title>
      <dc:creator>Joung Park</dc:creator>
      <pubDate>Wed, 07 Oct 2026 02:59:25 +0000</pubDate>
      <link>https://dev.to/joungpark/putting-on-the-product-owner-hat-2k1e</link>
      <guid>https://dev.to/joungpark/putting-on-the-product-owner-hat-2k1e</guid>
      <description>&lt;p&gt;I had the idea for Second-Memory.&lt;/p&gt;

&lt;p&gt;But at that point, it was still just an idea. It was too vague to start thinking about architecture or implementation.&lt;/p&gt;

&lt;p&gt;I wanted to develop the idea further before I started building it.&lt;/p&gt;

&lt;p&gt;So I put on the &lt;strong&gt;Product Owner hat&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;I followed the kind of process I had experienced in my professional work.&lt;/p&gt;

&lt;p&gt;I started by defining the initiative. I wrote down what I was trying to achieve and what I wanted the product to be.&lt;/p&gt;

&lt;p&gt;Then I wrote a PRD.&lt;/p&gt;

&lt;p&gt;Writing it made me think through the product in more detail: the problem, the user, the main use cases, the scope, and what I wanted to achieve with the first version.&lt;/p&gt;

&lt;p&gt;From there, I created user stories.&lt;/p&gt;

&lt;p&gt;This helped me look at the product from the user's perspective rather than thinking about how I was going to implement it.&lt;/p&gt;

&lt;p&gt;I then organised the stories into an initial backlog and broke the work down into tasks that I could eventually implement.&lt;/p&gt;

&lt;p&gt;There was another reason I wanted to document all of this.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;I knew I would be using AI tools to help me build the project.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Instead of explaining the project from scratch every time I asked an AI coding tool for help, I could give it the product documents as context.&lt;/p&gt;

&lt;p&gt;The PRD and user stories became a shared reference point — for me as the developer, and for the AI tools I was working with.&lt;/p&gt;

&lt;p&gt;The idea started in my head as something fairly vague.&lt;/p&gt;

&lt;p&gt;By the end of this process, I had something much more concrete:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;an idea → a product definition → user stories → work I could build.&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>product</category>
      <category>softwaredevelopment</category>
      <category>startup</category>
    </item>
  </channel>
</rss>
