<?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: João Abreu</title>
    <description>The latest articles on DEV Community by João Abreu (@joao_abreu).</description>
    <link>https://dev.to/joao_abreu</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%2F4192775%2F801a8876-dd3e-439c-becd-1e1aa990278a.png</url>
      <title>DEV Community: João Abreu</title>
      <link>https://dev.to/joao_abreu</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/joao_abreu"/>
    <language>en</language>
    <item>
      <title>Building a Waitlist with Automatic Promotion in Spring Boot (and Keeping the Queue Gap-Free)</title>
      <dc:creator>João Abreu</dc:creator>
      <pubDate>Sun, 01 Nov 2026 15:15:51 +0000</pubDate>
      <link>https://dev.to/joao_abreu/building-a-waitlist-with-automatic-promotion-in-spring-boot-and-keeping-the-queue-gap-free-3fk</link>
      <guid>https://dev.to/joao_abreu/building-a-waitlist-with-automatic-promotion-in-spring-boot-and-keeping-the-queue-gap-free-3fk</guid>
      <description>&lt;p&gt;🇺🇸 &lt;strong&gt;English version&lt;/strong&gt; (🇧🇷 versão em português logo abaixo)&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem: sessions with limited spots
&lt;/h2&gt;

&lt;p&gt;I'm building a &lt;strong&gt;tutoring management system&lt;/strong&gt; with Java, Spring Boot and PostgreSQL. Students can become tutors for a subject, and tutors open &lt;strong&gt;sessions&lt;/strong&gt; with a limited number of spots.&lt;/p&gt;

&lt;p&gt;That raised a real business question: what happens when a session is full? I didn't want to just return "Session is full". I wanted an optional &lt;strong&gt;waitlist&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;if there's a free spot, the student is &lt;strong&gt;confirmed&lt;/strong&gt;;&lt;/li&gt;
&lt;li&gt;if the session is full and allows a waitlist, the student goes to the &lt;strong&gt;queue&lt;/strong&gt;;&lt;/li&gt;
&lt;li&gt;when a confirmed student cancels, the &lt;strong&gt;first in line is promoted automatically&lt;/strong&gt; and gets notified;&lt;/li&gt;
&lt;li&gt;the rest of the queue moves up, &lt;strong&gt;with no gaps&lt;/strong&gt; (1, 2, 3... never 2, 3, 5).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fprjfendz069ubyznw5m4.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fprjfendz069ubyznw5m4.png" alt="Session with waitlist" width="800" height="385"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Modeling the enrollment
&lt;/h2&gt;

&lt;p&gt;Each enrollment has a status and an optional position in the queue:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;CONFIRMED&lt;/code&gt; → has a spot, &lt;code&gt;queuePosition&lt;/code&gt; is &lt;code&gt;null&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;WAITLISTED&lt;/code&gt; → waiting, &lt;code&gt;queuePosition&lt;/code&gt; is 1, 2, 3...&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;CANCELLED&lt;/code&gt; → left the session, &lt;code&gt;queuePosition&lt;/code&gt; is &lt;code&gt;null&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In the database, I made sure an invalid position can never be saved:&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;TABLE&lt;/span&gt; &lt;span class="n"&gt;enrollment&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;id&lt;/span&gt;             &lt;span class="n"&gt;UUID&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;status&lt;/span&gt;         &lt;span class="n"&gt;enrollment_status&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;queue_position&lt;/span&gt; &lt;span class="nb"&gt;INT&lt;/span&gt;               &lt;span class="k"&gt;CHECK&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;queue_position&lt;/span&gt; &lt;span class="o"&gt;&amp;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;session_id&lt;/span&gt;     &lt;span class="n"&gt;UUID&lt;/span&gt;              &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="k"&gt;session&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;student_id&lt;/span&gt;     &lt;span class="n"&gt;UUID&lt;/span&gt;              &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;created_at&lt;/span&gt;     &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt;       &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;NOW&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The repository only needs two Spring Data derived queries. No SQL written by hand:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kt"&gt;long&lt;/span&gt; &lt;span class="nf"&gt;countBySessionAndStatus&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Session&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;EnrollmentStatus&lt;/span&gt; &lt;span class="n"&gt;enrollmentStatus&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

&lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Enrollment&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;findBySessionAndStatusOrderByQueuePositionAsc&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Session&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;EnrollmentStatus&lt;/span&gt; &lt;span class="n"&gt;enrollmentStatus&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Enrolling: confirmed or waitlisted?
&lt;/h2&gt;

&lt;p&gt;After checking that the session exists, is active and the student isn't already enrolled, the &lt;code&gt;enroll&lt;/code&gt; method decides where the student goes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kt"&gt;long&lt;/span&gt; &lt;span class="n"&gt;confirmedCount&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;enrollmentRepository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;countBySessionAndStatus&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;EnrollmentStatus&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;CONFIRMED&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;confirmedCount&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getSpots&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;Enrollment&lt;/span&gt; &lt;span class="n"&gt;enrollment&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Enrollment&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;EnrollmentStatus&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;CONFIRMED&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;student&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;enrollmentRepository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;save&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;enrollment&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isAllowsWaitlist&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;long&lt;/span&gt; &lt;span class="n"&gt;waitlistedCount&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;enrollmentRepository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;countBySessionAndStatus&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;EnrollmentStatus&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;WAITLISTED&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

    &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;queuePosition&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="n"&gt;waitlistedCount&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="nc"&gt;Enrollment&lt;/span&gt; &lt;span class="n"&gt;enrollment&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Enrollment&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;EnrollmentStatus&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;WAITLISTED&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;student&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;enrollment&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setQueuePosition&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;queuePosition&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;enrollmentRepository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;save&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;enrollment&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;SessionFullException&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Session is full"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If there's no spot and no waitlist, a &lt;code&gt;SessionFullException&lt;/code&gt; is thrown, and my &lt;code&gt;@RestControllerAdvice&lt;/code&gt; turns it into a &lt;code&gt;409 Conflict&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cancelling: promoting the first in line
&lt;/h2&gt;

&lt;p&gt;This is the most interesting part. When a student cancels, three things must happen &lt;strong&gt;together&lt;/strong&gt;:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;the enrollment becomes &lt;code&gt;CANCELLED&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;if that student had a &lt;strong&gt;confirmed&lt;/strong&gt; spot, the first in the queue is promoted and notified;&lt;/li&gt;
&lt;li&gt;the queue is renumbered.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Transactional&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;Enrollment&lt;/span&gt; &lt;span class="nf"&gt;cancelEnrollment&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="no"&gt;UUID&lt;/span&gt; &lt;span class="n"&gt;enrollmentUuid&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="no"&gt;UUID&lt;/span&gt; &lt;span class="n"&gt;userUuid&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// ... find the enrollment, check permission and status&lt;/span&gt;

    &lt;span class="nc"&gt;EnrollmentStatus&lt;/span&gt; &lt;span class="n"&gt;previousStatus&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;enrollment&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getStatus&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;

    &lt;span class="n"&gt;enrollment&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setStatus&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;EnrollmentStatus&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;CANCELLED&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;enrollment&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setQueuePosition&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

    &lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Enrollment&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;waitlist&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;enrollmentRepository&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;findBySessionAndStatusOrderByQueuePositionAsc&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;enrollment&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getSession&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="nc"&gt;EnrollmentStatus&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;WAITLISTED&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;previousStatus&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="nc"&gt;EnrollmentStatus&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;CONFIRMED&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;waitlist&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isEmpty&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;Enrollment&lt;/span&gt; &lt;span class="n"&gt;promoted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;waitlist&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;get&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;promoted&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setStatus&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;EnrollmentStatus&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;CONFIRMED&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;promoted&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setQueuePosition&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="nc"&gt;Notification&lt;/span&gt; &lt;span class="n"&gt;notification&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Notification&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;NotificationEventType&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;SPOT_PROMOTED&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
                &lt;span class="s"&gt;"You have been promoted from the waitlist"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;promoted&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getStudent&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
        &lt;span class="n"&gt;notificationRepository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;save&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;notification&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// ... renumber the queue (next section)&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice &lt;code&gt;previousStatus&lt;/code&gt;: if the student who cancelled was only &lt;strong&gt;waiting&lt;/strong&gt;, no spot was freed, so nobody gets promoted. The queue still has to be renumbered, though.&lt;/p&gt;

&lt;h2&gt;
  
  
  Renumbering the queue without gaps
&lt;/h2&gt;

&lt;p&gt;After the promotion, the list still contains the promoted student (now &lt;code&gt;CONFIRMED&lt;/code&gt;). So I only renumber whoever is still &lt;code&gt;WAITLISTED&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;position&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="k"&gt;for&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Enrollment&lt;/span&gt; &lt;span class="n"&gt;waiting&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;waitlist&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;waiting&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getStatus&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="nc"&gt;EnrollmentStatus&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;WAITLISTED&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;waiting&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setQueuePosition&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;position&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;position&lt;/span&gt;&lt;span class="o"&gt;++;&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="n"&gt;enrollmentRepository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;saveAll&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;waitlist&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;enrollmentRepository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;save&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;enrollment&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;@Transactional&lt;/code&gt; is what makes this safe: cancelling, promoting, notifying and renumbering are &lt;strong&gt;all or nothing&lt;/strong&gt;. If anything fails halfway, nothing is saved, and I never end up with a cancelled spot and nobody promoted.&lt;/p&gt;

&lt;h2&gt;
  
  
  Proving it with unit tests
&lt;/h2&gt;

&lt;p&gt;I tested the service with &lt;strong&gt;JUnit 5 and Mockito&lt;/strong&gt;. The main scenario: a queue with positions 1, 2 and 3, and a confirmed student cancels. Expected result: the 1st becomes &lt;code&gt;CONFIRMED&lt;/code&gt;, the others become 1 and 2, and a notification goes to the right student.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nc"&gt;Enrollment&lt;/span&gt; &lt;span class="n"&gt;enrollmentTest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sessionService&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;cancelEnrollment&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;enrollmentUuid&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;student&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getId&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;

&lt;span class="nc"&gt;Assertions&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;assertEquals&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;EnrollmentStatus&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;CANCELLED&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;enrollmentTest&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getStatus&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
&lt;span class="nc"&gt;Assertions&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;assertNull&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;enrollmentTest&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getQueuePosition&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;

&lt;span class="nc"&gt;Assertions&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;assertEquals&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;EnrollmentStatus&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;CONFIRMED&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;firstWaiting&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getStatus&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
&lt;span class="nc"&gt;Assertions&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;assertNull&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;firstWaiting&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getQueuePosition&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
&lt;span class="nc"&gt;Assertions&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;assertEquals&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;secondWaiting&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getQueuePosition&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
&lt;span class="nc"&gt;Assertions&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;assertEquals&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;thirdWaiting&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getQueuePosition&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;

&lt;span class="nc"&gt;ArgumentCaptor&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Notification&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;notification&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ArgumentCaptor&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;forClass&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Notification&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;class&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;verify&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;notificationRepository&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;save&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;notification&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;capture&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;

&lt;span class="nc"&gt;Assertions&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;assertEquals&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;NotificationEventType&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;SPOT_PROMOTED&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;notification&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getValue&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;getEventType&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
&lt;span class="nc"&gt;Assertions&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;assertEquals&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;promotedStudent&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;notification&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getValue&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;getReceiver&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;ArgumentCaptor&lt;/code&gt; was new to me: it lets you grab the object passed to the mock and check &lt;strong&gt;what&lt;/strong&gt; was saved, not only &lt;strong&gt;that&lt;/strong&gt; something was saved.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I learned and next steps
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Modeling the states (&lt;code&gt;CONFIRMED&lt;/code&gt;, &lt;code&gt;WAITLISTED&lt;/code&gt;, &lt;code&gt;CANCELLED&lt;/code&gt;) first made the code much simpler.&lt;/li&gt;
&lt;li&gt;Validating in two places (service + &lt;code&gt;CHECK&lt;/code&gt; constraint in the database) protects data even if a bug slips through.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;@Transactional&lt;/code&gt; matters most when one action changes several rows.&lt;/li&gt;
&lt;li&gt;A next step: &lt;code&gt;enroll&lt;/code&gt; checks the count and then saves. Two students enrolling at the exact same moment could both see the last spot free. I want to study locking (for example, a pessimistic lock on the session) to handle that.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The full code is on GitHub: &lt;a href="https://github.com/jg-abreu/tutoring-management-system" rel="noopener noreferrer"&gt;jg-abreu/tutoring-management-system&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Thanks for reading! Feedback is very welcome. 🙌&lt;/p&gt;




&lt;h2&gt;
  
  
  🇧🇷 Versão em português
&lt;/h2&gt;

&lt;h2&gt;
  
  
  Criando uma lista de espera com promoção automática no Spring Boot (sem buracos na fila)
&lt;/h2&gt;

&lt;h2&gt;
  
  
  O problema: sessões com vagas limitadas
&lt;/h2&gt;

&lt;p&gt;Estou construindo um &lt;strong&gt;sistema de gestão de monitorias&lt;/strong&gt; com Java, Spring Boot e PostgreSQL. Alunos podem virar monitores de uma disciplina, e os monitores abrem &lt;strong&gt;sessões&lt;/strong&gt; com um número limitado de vagas.&lt;/p&gt;

&lt;p&gt;Isso trouxe uma pergunta de negócio real: o que acontece quando a sessão lota? Eu não queria só responder "Session is full". Queria uma &lt;strong&gt;lista de espera&lt;/strong&gt; opcional:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;se tem vaga, o aluno fica &lt;strong&gt;confirmado&lt;/strong&gt;;&lt;/li&gt;
&lt;li&gt;se a sessão está cheia e aceita lista de espera, o aluno entra na &lt;strong&gt;fila&lt;/strong&gt;;&lt;/li&gt;
&lt;li&gt;quando um aluno confirmado cancela, o &lt;strong&gt;primeiro da fila é promovido automaticamente&lt;/strong&gt; e recebe uma notificação;&lt;/li&gt;
&lt;li&gt;o resto da fila anda, &lt;strong&gt;sem buracos&lt;/strong&gt; (1, 2, 3... nunca 2, 3, 5).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fprjfendz069ubyznw5m4.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fprjfendz069ubyznw5m4.png" alt="Sessão com lista de espera" width="800" height="385"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Modelando a inscrição
&lt;/h2&gt;

&lt;p&gt;Cada inscrição tem um status e uma posição opcional na fila:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;CONFIRMED&lt;/code&gt; → tem vaga, &lt;code&gt;queuePosition&lt;/code&gt; é &lt;code&gt;null&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;WAITLISTED&lt;/code&gt; → esperando, &lt;code&gt;queuePosition&lt;/code&gt; é 1, 2, 3...&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;CANCELLED&lt;/code&gt; → saiu da sessão, &lt;code&gt;queuePosition&lt;/code&gt; é &lt;code&gt;null&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;No banco, garanti que uma posição inválida nunca seja salva:&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;TABLE&lt;/span&gt; &lt;span class="n"&gt;enrollment&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;id&lt;/span&gt;             &lt;span class="n"&gt;UUID&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;status&lt;/span&gt;         &lt;span class="n"&gt;enrollment_status&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;queue_position&lt;/span&gt; &lt;span class="nb"&gt;INT&lt;/span&gt;               &lt;span class="k"&gt;CHECK&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;queue_position&lt;/span&gt; &lt;span class="o"&gt;&amp;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;session_id&lt;/span&gt;     &lt;span class="n"&gt;UUID&lt;/span&gt;              &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="k"&gt;session&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;student_id&lt;/span&gt;     &lt;span class="n"&gt;UUID&lt;/span&gt;              &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;created_at&lt;/span&gt;     &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt;       &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;NOW&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;O repositório só precisa de duas derived queries do Spring Data, sem escrever SQL na mão:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kt"&gt;long&lt;/span&gt; &lt;span class="nf"&gt;countBySessionAndStatus&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Session&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;EnrollmentStatus&lt;/span&gt; &lt;span class="n"&gt;enrollmentStatus&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

&lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Enrollment&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;findBySessionAndStatusOrderByQueuePositionAsc&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Session&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;EnrollmentStatus&lt;/span&gt; &lt;span class="n"&gt;enrollmentStatus&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Inscrevendo: confirmado ou na fila?
&lt;/h2&gt;

&lt;p&gt;Depois de verificar que a sessão existe, está ativa e o aluno ainda não está inscrito, o método &lt;code&gt;enroll&lt;/code&gt; decide para onde o aluno vai:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kt"&gt;long&lt;/span&gt; &lt;span class="n"&gt;confirmedCount&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;enrollmentRepository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;countBySessionAndStatus&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;EnrollmentStatus&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;CONFIRMED&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;confirmedCount&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getSpots&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;Enrollment&lt;/span&gt; &lt;span class="n"&gt;enrollment&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Enrollment&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;EnrollmentStatus&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;CONFIRMED&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;student&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;enrollmentRepository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;save&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;enrollment&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isAllowsWaitlist&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;long&lt;/span&gt; &lt;span class="n"&gt;waitlistedCount&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;enrollmentRepository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;countBySessionAndStatus&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;EnrollmentStatus&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;WAITLISTED&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

    &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;queuePosition&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="n"&gt;waitlistedCount&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="nc"&gt;Enrollment&lt;/span&gt; &lt;span class="n"&gt;enrollment&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Enrollment&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;EnrollmentStatus&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;WAITLISTED&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;student&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;enrollment&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setQueuePosition&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;queuePosition&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;enrollmentRepository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;save&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;enrollment&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;SessionFullException&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Session is full"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Se não tem vaga nem lista de espera, é lançada uma &lt;code&gt;SessionFullException&lt;/code&gt;, que o meu &lt;code&gt;@RestControllerAdvice&lt;/code&gt; transforma em &lt;code&gt;409 Conflict&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cancelando: promovendo o primeiro da fila
&lt;/h2&gt;

&lt;p&gt;Essa é a parte mais interessante. Quando um aluno cancela, três coisas precisam acontecer &lt;strong&gt;juntas&lt;/strong&gt;:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;a inscrição vira &lt;code&gt;CANCELLED&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;se esse aluno tinha vaga &lt;strong&gt;confirmada&lt;/strong&gt;, o primeiro da fila é promovido e notificado;&lt;/li&gt;
&lt;li&gt;a fila é renumerada.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Transactional&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;Enrollment&lt;/span&gt; &lt;span class="nf"&gt;cancelEnrollment&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="no"&gt;UUID&lt;/span&gt; &lt;span class="n"&gt;enrollmentUuid&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="no"&gt;UUID&lt;/span&gt; &lt;span class="n"&gt;userUuid&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// ... busca a inscrição, verifica permissão e status&lt;/span&gt;

    &lt;span class="nc"&gt;EnrollmentStatus&lt;/span&gt; &lt;span class="n"&gt;previousStatus&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;enrollment&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getStatus&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;

    &lt;span class="n"&gt;enrollment&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setStatus&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;EnrollmentStatus&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;CANCELLED&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;enrollment&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setQueuePosition&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

    &lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Enrollment&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;waitlist&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;enrollmentRepository&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;findBySessionAndStatusOrderByQueuePositionAsc&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;enrollment&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getSession&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="nc"&gt;EnrollmentStatus&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;WAITLISTED&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;previousStatus&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="nc"&gt;EnrollmentStatus&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;CONFIRMED&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;waitlist&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isEmpty&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;Enrollment&lt;/span&gt; &lt;span class="n"&gt;promoted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;waitlist&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;get&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;promoted&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setStatus&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;EnrollmentStatus&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;CONFIRMED&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;promoted&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setQueuePosition&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="nc"&gt;Notification&lt;/span&gt; &lt;span class="n"&gt;notification&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Notification&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;NotificationEventType&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;SPOT_PROMOTED&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
                &lt;span class="s"&gt;"You have been promoted from the waitlist"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;promoted&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getStudent&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
        &lt;span class="n"&gt;notificationRepository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;save&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;notification&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// ... renumera a fila (próxima seção)&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Repare no &lt;code&gt;previousStatus&lt;/code&gt;: se quem cancelou estava só &lt;strong&gt;esperando&lt;/strong&gt;, nenhuma vaga foi liberada, então ninguém é promovido. Mas a fila ainda precisa ser renumerada.&lt;/p&gt;

&lt;h2&gt;
  
  
  Renumerando a fila sem buracos
&lt;/h2&gt;

&lt;p&gt;Depois da promoção, a lista ainda contém o aluno promovido (agora &lt;code&gt;CONFIRMED&lt;/code&gt;). Por isso só renumero quem continua &lt;code&gt;WAITLISTED&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;position&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="k"&gt;for&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Enrollment&lt;/span&gt; &lt;span class="n"&gt;waiting&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;waitlist&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;waiting&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getStatus&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="nc"&gt;EnrollmentStatus&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;WAITLISTED&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;waiting&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setQueuePosition&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;position&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;position&lt;/span&gt;&lt;span class="o"&gt;++;&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="n"&gt;enrollmentRepository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;saveAll&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;waitlist&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;enrollmentRepository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;save&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;enrollment&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;O &lt;code&gt;@Transactional&lt;/code&gt; é o que torna isso seguro: cancelar, promover, notificar e renumerar é &lt;strong&gt;tudo ou nada&lt;/strong&gt;. Se algo falhar no meio, nada é salvo, e nunca fico com uma vaga cancelada sem ninguém promovido.&lt;/p&gt;

&lt;h2&gt;
  
  
  Provando com testes unitários
&lt;/h2&gt;

&lt;p&gt;Testei o service com &lt;strong&gt;JUnit 5 e Mockito&lt;/strong&gt;. O cenário principal: uma fila com posições 1, 2 e 3, e um aluno confirmado cancela. Resultado esperado: o 1º vira &lt;code&gt;CONFIRMED&lt;/code&gt;, os outros viram 1 e 2, e uma notificação vai para o aluno certo.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nc"&gt;Enrollment&lt;/span&gt; &lt;span class="n"&gt;enrollmentTest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sessionService&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;cancelEnrollment&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;enrollmentUuid&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;student&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getId&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;

&lt;span class="nc"&gt;Assertions&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;assertEquals&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;EnrollmentStatus&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;CANCELLED&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;enrollmentTest&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getStatus&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
&lt;span class="nc"&gt;Assertions&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;assertNull&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;enrollmentTest&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getQueuePosition&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;

&lt;span class="nc"&gt;Assertions&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;assertEquals&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;EnrollmentStatus&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;CONFIRMED&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;firstWaiting&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getStatus&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
&lt;span class="nc"&gt;Assertions&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;assertNull&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;firstWaiting&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getQueuePosition&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
&lt;span class="nc"&gt;Assertions&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;assertEquals&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;secondWaiting&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getQueuePosition&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
&lt;span class="nc"&gt;Assertions&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;assertEquals&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;thirdWaiting&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getQueuePosition&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;

&lt;span class="nc"&gt;ArgumentCaptor&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Notification&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;notification&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ArgumentCaptor&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;forClass&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Notification&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;class&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;verify&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;notificationRepository&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;save&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;notification&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;capture&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;

&lt;span class="nc"&gt;Assertions&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;assertEquals&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;NotificationEventType&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;SPOT_PROMOTED&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;notification&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getValue&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;getEventType&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
&lt;span class="nc"&gt;Assertions&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;assertEquals&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;promotedStudent&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;notification&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getValue&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;getReceiver&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;O &lt;code&gt;ArgumentCaptor&lt;/code&gt; era novidade para mim: ele permite pegar o objeto passado para o mock e verificar &lt;strong&gt;o que&lt;/strong&gt; foi salvo, e não só &lt;strong&gt;que&lt;/strong&gt; algo foi salvo.&lt;/p&gt;

&lt;h2&gt;
  
  
  O que aprendi e próximos passos
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Modelar os estados primeiro (&lt;code&gt;CONFIRMED&lt;/code&gt;, &lt;code&gt;WAITLISTED&lt;/code&gt;, &lt;code&gt;CANCELLED&lt;/code&gt;) deixou o código muito mais simples.&lt;/li&gt;
&lt;li&gt;Validar em dois lugares (service + constraint &lt;code&gt;CHECK&lt;/code&gt; no banco) protege os dados mesmo se um bug passar.&lt;/li&gt;
&lt;li&gt;O &lt;code&gt;@Transactional&lt;/code&gt; faz mais diferença quando uma ação altera várias linhas.&lt;/li&gt;
&lt;li&gt;Um próximo passo: o &lt;code&gt;enroll&lt;/code&gt; conta e depois salva. Dois alunos se inscrevendo exatamente no mesmo momento poderiam ver a última vaga livre ao mesmo tempo. Quero estudar locking (por exemplo, um lock pessimista na sessão) para tratar isso.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;O código completo está no GitHub: &lt;a href="https://github.com/jg-abreu/tutoring-management-system" rel="noopener noreferrer"&gt;jg-abreu/tutoring-management-system&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Obrigado por ler! Feedback é muito bem-vindo. 🙌&lt;/p&gt;

</description>
      <category>java</category>
      <category>springboot</category>
      <category>backend</category>
      <category>jpa</category>
    </item>
    <item>
      <title>Why my GETs worked but every POST got a 403: a CORS bug behind nginx in Docker</title>
      <dc:creator>João Abreu</dc:creator>
      <pubDate>Tue, 20 Oct 2026 18:03:49 +0000</pubDate>
      <link>https://dev.to/joao_abreu/why-my-gets-worked-but-every-post-got-a-403-a-cors-bug-behind-nginx-in-docker-2io</link>
      <guid>https://dev.to/joao_abreu/why-my-gets-worked-but-every-post-got-a-403-a-cors-bug-behind-nginx-in-docker-2io</guid>
      <description>&lt;p&gt;I'm a CS student building a tutoring management system: a Spring Boot REST API, a React frontend and PostgreSQL. While preparing the v1.2 release, I added a Docker Compose setup so anyone could run the whole stack with one command.&lt;/p&gt;

&lt;p&gt;It almost worked. The pages loaded, the lists showed up... and every time I clicked a button that changed something, the API answered:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;403 Forbidden
Invalid CORS request
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This post is about why that happened, why only &lt;em&gt;some&lt;/em&gt; requests failed, and the one-line fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;Three containers:&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;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;db&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;        &lt;span class="c1"&gt;# PostgreSQL&lt;/span&gt;
  &lt;span class="na"&gt;api&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;       &lt;span class="c1"&gt;# Spring Boot, port 8080&lt;/span&gt;
  &lt;span class="na"&gt;frontend&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;  &lt;span class="c1"&gt;# nginx serving the React build, exposed as localhost:3000&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The frontend doesn't call the API directly. nginx proxies everything under &lt;code&gt;/api/&lt;/code&gt; to the &lt;code&gt;api&lt;/code&gt; container, so from the browser's point of view the frontend and the API live on the &lt;strong&gt;same origin&lt;/strong&gt;, &lt;code&gt;http://localhost:3000&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/api/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_pass&lt;/span&gt; &lt;span class="s"&gt;http://api:8080/&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;Host&lt;/span&gt; &lt;span class="nv"&gt;$host&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;On the Spring side, I already had a CORS config for local development, when the frontend runs on Vite's dev server:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="n"&gt;registry&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;addMapping&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/**"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;allowedOrigins&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"http://localhost:5173"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;allowedMethods&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"GET"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"POST"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"PATCH"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same origin through the proxy, so CORS shouldn't even matter here. Right?&lt;/p&gt;

&lt;h2&gt;
  
  
  The weird part: only POST and PATCH failed
&lt;/h2&gt;

&lt;p&gt;Listing subjects and sessions (GET) worked fine. Suggesting a subject, joining a session or approving something (POST/PATCH) all returned 403.&lt;/p&gt;

&lt;p&gt;That was the clue. Browsers send the &lt;code&gt;Origin&lt;/code&gt; header on POST and PATCH requests even when they are same-origin, but &lt;strong&gt;not&lt;/strong&gt; on same-origin GETs. Spring only runs its CORS checks when an &lt;code&gt;Origin&lt;/code&gt; header is present. So the GETs never went through the CORS logic at all, and the others did.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Spring decides whether a request is "cross-origin"
&lt;/h2&gt;

&lt;p&gt;When a request has an &lt;code&gt;Origin&lt;/code&gt; header, Spring compares it with the request's own scheme, host and port. Spring builds that from the &lt;code&gt;Host&lt;/code&gt; header it receives. If they match, it's a same-origin request and CORS is skipped. If they don't, it's a CORS request and the origin must be in &lt;code&gt;allowedOrigins&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Now look at what each side saw:&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;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;Origin&lt;/code&gt; sent by the browser&lt;/td&gt;
&lt;td&gt;&lt;code&gt;http://localhost:3000&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;Host&lt;/code&gt; forwarded by nginx with &lt;code&gt;$host&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;localhost&lt;/code&gt; (no port)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;What Spring thinks it is&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;http://localhost&lt;/code&gt; (port 80)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;localhost:3000&lt;/code&gt; ≠ &lt;code&gt;localhost:80&lt;/code&gt;, so Spring treated a same-origin request as cross-origin. And since &lt;code&gt;http://localhost:3000&lt;/code&gt; wasn't in my allowed origins (only the Vite one was), it rejected it with &lt;code&gt;Invalid CORS request&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The culprit was &lt;code&gt;$host&lt;/code&gt;. In nginx, &lt;code&gt;$host&lt;/code&gt; is the host name &lt;strong&gt;without the port&lt;/strong&gt;. &lt;code&gt;$http_host&lt;/code&gt; is the &lt;code&gt;Host&lt;/code&gt; header exactly as the browser sent it, &lt;strong&gt;with the port&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;One variable:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/api/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_pass&lt;/span&gt; &lt;span class="s"&gt;http://api:8080/&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="c1"&gt;# $http_host keeps the port (localhost:3000), so Spring sees the same origin as the&lt;/span&gt;
    &lt;span class="c1"&gt;# browser and skips CORS; $host drops it and every POST/PATCH failed with 403.&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;Host&lt;/span&gt; &lt;span class="nv"&gt;$http_host&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;Now Spring sees &lt;code&gt;Host: localhost:3000&lt;/code&gt;, which matches &lt;code&gt;Origin: http://localhost:3000&lt;/code&gt;, and the request is same-origin again. No CORS check, no 403.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I didn't just add localhost:3000 to allowedOrigins
&lt;/h2&gt;

&lt;p&gt;That would also make the error go away, and it's the first answer you find online. But it hides the real problem: the requests &lt;em&gt;are&lt;/em&gt; same-origin, the proxy was just lying to the backend about where they came from. Fixing the header keeps the CORS config meaningful (it only lists origins that are really different, like the Vite dev server) and avoids a whitelist that has to change every time the port changes.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I took away from it
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;"Only some methods fail" is a strong hint.&lt;/strong&gt; In this case it pointed straight at the &lt;code&gt;Origin&lt;/code&gt; header.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A reverse proxy changes what the backend sees.&lt;/strong&gt; Headers like &lt;code&gt;Host&lt;/code&gt; decide how the app understands the request, so they're worth checking first.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fix the cause, not the symptom.&lt;/strong&gt; Widening CORS would have worked, but it would have been the wrong fix.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The project is open source if you want to see the full Docker setup: &lt;a href="https://github.com/jg-abreu/tutoring-management-system" rel="noopener noreferrer"&gt;tutoring-management-system&lt;/a&gt;. The fix is in v1.2.&lt;/p&gt;

&lt;p&gt;Have you ever been bitten by a proxy header? I'd like to hear how you debugged it.&lt;/p&gt;




&lt;h2&gt;
  
  
  🇧🇷 Versão em português
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Por que meus GETs funcionavam, mas todo POST dava 403: um bug de CORS atrás do nginx no Docker&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Sou estudante de Ciência da Computação e estou construindo um sistema de gestão de monitorias: uma API REST em Spring Boot, um frontend em React e PostgreSQL. Preparando a versão 1.2, criei um Docker Compose pra qualquer pessoa rodar o sistema inteiro com um único comando.&lt;/p&gt;

&lt;p&gt;Quase funcionou. As páginas carregavam, as listas apareciam... e toda vez que eu clicava num botão que alterava alguma coisa, a API respondia:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;403 Forbidden
Invalid CORS request
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Este post explica por que isso aconteceu, por que só &lt;em&gt;algumas&lt;/em&gt; requisições falhavam e a correção de uma linha.&lt;/p&gt;

&lt;h2&gt;
  
  
  O cenário
&lt;/h2&gt;

&lt;p&gt;Três containers:&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;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;db&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;        &lt;span class="c1"&gt;# PostgreSQL&lt;/span&gt;
  &lt;span class="na"&gt;api&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;       &lt;span class="c1"&gt;# Spring Boot, porta 8080&lt;/span&gt;
  &lt;span class="na"&gt;frontend&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;  &lt;span class="c1"&gt;# nginx servindo o build do React, exposto em localhost:3000&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;O frontend não chama a API diretamente. O nginx repassa tudo que está em &lt;code&gt;/api/&lt;/code&gt; para o container &lt;code&gt;api&lt;/code&gt;, então, do ponto de vista do navegador, frontend e API estão na &lt;strong&gt;mesma origem&lt;/strong&gt;, &lt;code&gt;http://localhost:3000&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/api/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_pass&lt;/span&gt; &lt;span class="s"&gt;http://api:8080/&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;Host&lt;/span&gt; &lt;span class="nv"&gt;$host&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;No Spring, eu já tinha uma configuração de CORS para o desenvolvimento local, quando o frontend roda no servidor do Vite:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="n"&gt;registry&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;addMapping&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/**"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;allowedOrigins&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"http://localhost:5173"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;allowedMethods&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"GET"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"POST"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"PATCH"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Mesma origem pelo proxy, então o CORS nem deveria importar aqui. Certo?&lt;/p&gt;

&lt;h2&gt;
  
  
  A parte estranha: só POST e PATCH falhavam
&lt;/h2&gt;

&lt;p&gt;Listar matérias e sessões (GET) funcionava. Sugerir uma matéria, entrar numa sessão ou aprovar algo (POST/PATCH) devolvia 403.&lt;/p&gt;

&lt;p&gt;Essa foi a pista. O navegador envia o cabeçalho &lt;code&gt;Origin&lt;/code&gt; em requisições POST e PATCH mesmo quando são da mesma origem, mas &lt;strong&gt;não&lt;/strong&gt; em GETs da mesma origem. E o Spring só roda as verificações de CORS quando existe um cabeçalho &lt;code&gt;Origin&lt;/code&gt;. Ou seja, os GETs nem passavam pela lógica de CORS, e os outros passavam.&lt;/p&gt;

&lt;h2&gt;
  
  
  Como o Spring decide se uma requisição é "cross-origin"
&lt;/h2&gt;

&lt;p&gt;Quando a requisição tem &lt;code&gt;Origin&lt;/code&gt;, o Spring compara esse valor com o esquema, host e porta da própria requisição, que ele monta a partir do cabeçalho &lt;code&gt;Host&lt;/code&gt; que recebe. Se baterem, é mesma origem e o CORS é ignorado. Se não, é uma requisição CORS e a origem precisa estar em &lt;code&gt;allowedOrigins&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Veja o que cada lado enxergava:&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;Valor&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;Origin&lt;/code&gt; enviado pelo navegador&lt;/td&gt;
&lt;td&gt;&lt;code&gt;http://localhost:3000&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;Host&lt;/code&gt; repassado pelo nginx com &lt;code&gt;$host&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;localhost&lt;/code&gt; (sem porta)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;O que o Spring acha que é&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;http://localhost&lt;/code&gt; (porta 80)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;localhost:3000&lt;/code&gt; ≠ &lt;code&gt;localhost:80&lt;/code&gt;, então o Spring tratou uma requisição da mesma origem como cross-origin. E como &lt;code&gt;http://localhost:3000&lt;/code&gt; não estava nas origens permitidas (só a do Vite), ele rejeitou com &lt;code&gt;Invalid CORS request&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;O culpado era o &lt;code&gt;$host&lt;/code&gt;. No nginx, &lt;code&gt;$host&lt;/code&gt; é o nome do host &lt;strong&gt;sem a porta&lt;/strong&gt;. Já &lt;code&gt;$http_host&lt;/code&gt; é o cabeçalho &lt;code&gt;Host&lt;/code&gt; exatamente como o navegador enviou, &lt;strong&gt;com a porta&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  A correção
&lt;/h2&gt;

&lt;p&gt;Uma variável:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/api/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_pass&lt;/span&gt; &lt;span class="s"&gt;http://api:8080/&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;Host&lt;/span&gt; &lt;span class="nv"&gt;$http_host&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;Agora o Spring recebe &lt;code&gt;Host: localhost:3000&lt;/code&gt;, que bate com &lt;code&gt;Origin: http://localhost:3000&lt;/code&gt;, e a requisição volta a ser da mesma origem. Sem verificação de CORS, sem 403.&lt;/p&gt;

&lt;h2&gt;
  
  
  Por que não adicionei localhost:3000 no allowedOrigins
&lt;/h2&gt;

&lt;p&gt;Isso também faria o erro sumir, e é a primeira resposta que aparece na internet. Mas esconde o problema real: as requisições &lt;em&gt;são&lt;/em&gt; da mesma origem, o proxy é que estava informando errado ao backend de onde elas vinham. Corrigir o cabeçalho mantém a configuração de CORS com sentido (ela só lista origens realmente diferentes, como o servidor do Vite) e evita uma lista que precisa mudar toda vez que a porta mudar.&lt;/p&gt;

&lt;h2&gt;
  
  
  O que eu levei disso
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;"Só alguns métodos falham" é uma pista forte.&lt;/strong&gt; Nesse caso, apontou direto para o cabeçalho &lt;code&gt;Origin&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Um proxy reverso muda o que o backend enxerga.&lt;/strong&gt; Cabeçalhos como &lt;code&gt;Host&lt;/code&gt; definem como a aplicação entende a requisição, então vale olhar para eles primeiro.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Corrija a causa, não o sintoma.&lt;/strong&gt; Liberar mais origens no CORS funcionaria, mas seria a correção errada.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;O projeto é open source, se quiser ver o Docker completo: &lt;a href="https://github.com/jg-abreu/tutoring-management-system" rel="noopener noreferrer"&gt;tutoring-management-system&lt;/a&gt;. A correção está na v1.2.&lt;/p&gt;

&lt;p&gt;Já foi pego por um cabeçalho de proxy? Conta como você depurou.&lt;/p&gt;

</description>
      <category>springboot</category>
      <category>docker</category>
      <category>nginx</category>
      <category>cors</category>
    </item>
  </channel>
</rss>
