<?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: BootSaaS</title>
    <description>The latest articles on DEV Community by BootSaaS (@bootsaas).</description>
    <link>https://dev.to/bootsaas</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%2F4096214%2F7c43b615-029b-401a-88c7-d0ee2539197c.png</url>
      <title>DEV Community: BootSaaS</title>
      <link>https://dev.to/bootsaas</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/bootsaas"/>
    <language>en</language>
    <item>
      <title>One Missing WHERE Clause Can Expose Another Customer’s Data</title>
      <dc:creator>BootSaaS</dc:creator>
      <pubDate>Thu, 17 Sep 2026 21:51:46 +0000</pubDate>
      <link>https://dev.to/bootsaas/one-missing-where-clause-can-expose-another-customers-data-2n0c</link>
      <guid>https://dev.to/bootsaas/one-missing-where-clause-can-expose-another-customers-data-2n0c</guid>
      <description>&lt;p&gt;A customer opens their dashboard and sees another company’s invoices.&lt;/p&gt;

&lt;p&gt;Nobody needed to break in. An endpoint returned data it should never have returned.&lt;/p&gt;

&lt;p&gt;That’s one of the mistakes I want to make harder to introduce when building a B2B SaaS. Authentication alone doesn’t solve it: a user can be correctly logged in and still receive another customer’s data.&lt;/p&gt;

&lt;p&gt;For &lt;a href="https://bootsaas.dev" rel="noopener noreferrer"&gt;BootSaaS&lt;/a&gt;, I chose a schema-per-tenant architecture with Spring Boot, PostgreSQL, and Liquibase. I want separate business tables for each customer, a shared connection budget, and deployments a small team can operate. For that combination, schema-per-tenant is my clear choice.&lt;/p&gt;

&lt;p&gt;This is an architecture walkthrough. The examples illustrate the design rather than provide a complete implementation.&lt;/p&gt;

&lt;h3&gt;
  
  
  First, what counts as a tenant?
&lt;/h3&gt;

&lt;p&gt;In BootSaaS, a tenant is a customer organization or workspace. Each user belongs to exactly one tenant, and a tenant can have several users. This is the model used throughout this article.&lt;/p&gt;

&lt;p&gt;Tenant isolation answers one question: which organization’s data can this request access?&lt;/p&gt;

&lt;p&gt;Permissions inside that organization answer another: can this member view invoices, invite teammates, or change settings? You need both.&lt;/p&gt;

&lt;h3&gt;
  
  
  How one missing filter can expose customer data
&lt;/h3&gt;

&lt;p&gt;Consider a shared invoices table:&lt;/p&gt;

&lt;p&gt;Invoice 101 belongs to Acme (&lt;code&gt;tenant_id=acme&lt;/code&gt;) and has an amount of 1200.&lt;br&gt;
Invoice 102 belongs to Globex (&lt;code&gt;tenant_id=globex&lt;/code&gt;) and has an amount of 850.&lt;/p&gt;

&lt;p&gt;To retrieve Acme’s invoices, an application might execute:&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;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;amount&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;invoices&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;tenant_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;current_tenant&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now imagine a new export endpoint uses this query:&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;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;amount&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;invoices&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If application-level filtering is the only isolation mechanism, this returns both customers’ invoices. The SQL is valid. The endpoint may even pass a test suite containing only one tenant.&lt;/p&gt;

&lt;p&gt;The same mistake can affect a lookup by ID, an update, or a delete. A globally unique invoice ID prevents collisions; it doesn’t establish that the current user may access that invoice.&lt;/p&gt;

&lt;p&gt;I wouldn’t build tenant isolation around everyone remembering a filter. ORM support can centralize filtering, while PostgreSQL’s row-level security can enforce policies in the database. With RLS, the application role matters: superusers, roles with BYPASSRLS, and normally table owners bypass those policies. PostgreSQL documents these rules &lt;a href="https://www.postgresql.org/docs/current/ddl-rowsecurity.html" rel="noopener noreferrer"&gt;here&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The weak point in the example is relying entirely on every application query remembering the tenant restriction.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three common storage approaches
&lt;/h2&gt;

&lt;p&gt;My target here is a B2B SaaS with many workspaces, broadly the same data model, and a small engineering team. For that workload, I don’t consider these three approaches equally attractive.&lt;/p&gt;

&lt;h3&gt;
  
  
  Shared tables: simple operations, isolation through row scoping
&lt;/h3&gt;

&lt;p&gt;Every customer shares the same tables, with a tenant identifier on tenant-owned rows. One structure to migrate and a shared connection pool make this operationally attractive.&lt;/p&gt;

&lt;p&gt;But the tenant boundary depends on correctly enforced row scoping. If I chose this design, centralized enforcement would be a requirement. For BootSaaS, I want each customer’s business records in separate tables, so I chose a different boundary.&lt;/p&gt;

&lt;h3&gt;
  
  
  Database per tenant: too much operational machinery for my default
&lt;/h3&gt;

&lt;p&gt;For the SaaS I’m building, I would reject database-per-tenant as the default. The operational cost starts inside the Spring Boot application.&lt;/p&gt;

&lt;p&gt;Spring Boot normally uses HikariCP when you include the JDBC or JPA starter. With a straightforward setup using one &lt;code&gt;HikariDataSource&lt;/code&gt;per database, 1,000 tenant databases means managing 1,000 separate pools if you keep them all active. &lt;a href="https://docs.spring.io/spring-boot/reference/data/sql.html" rel="noopener noreferrer"&gt;Spring Boot documentation&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Each pool brings connection objects, housekeeping, and a lifecycle to manage. Retaining idle connections across hundreds of mostly inactive tenants wastes resources. For example, configuring a minimum of five idle connections in each of 1,000 active pools means asking those pools to maintain 5,000 idle connections in total. That is a configuration example, not an unavoidable connection count. &lt;a href="https://github.com/brettwooldridge/HikariCP#gear-configuration-knobs-baby" rel="noopener noreferrer"&gt;HikariCP configuration&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;You can create pools on demand and close unused ones. But now your application needs to manage pool creation, eviction, and concurrent access. I don’t want that additional machinery to be the default cost of creating a workspace.&lt;/p&gt;

&lt;p&gt;I would reconsider separate databases for a concrete customer requirement that justifies their operational cost, but I wouldn’t build that complexity into a standard SaaS by default.&lt;/p&gt;

&lt;h3&gt;
  
  
  Schema per tenant: the sweet spot for BootSaaS
&lt;/h3&gt;

&lt;p&gt;Schema-per-tenant means using one PostgreSQL database with a separate schema for each customer organization. A schema is a named container for database objects, including tables. Each tenant gets its own set of business tables inside that container.&lt;/p&gt;

&lt;p&gt;For example, Acme’s invoices live in &lt;code&gt;acme.invoices&lt;/code&gt;, while Globex’s invoices live in &lt;code&gt;globex.invoices&lt;/code&gt;. These are two distinct tables inside the same database.&lt;/p&gt;

&lt;p&gt;With a shared application database role and deliberate schema selection, connections can be reused across tenants. The team can size a common pool for actual concurrent work. Requiring a separate database role for every tenant would change that pooling calculation.&lt;/p&gt;

&lt;p&gt;This gives me the combination I want: explicit separation of business tables, centralized tenant routing, and a common migration definition applied through one database connection infrastructure. Access checks, privileges, and isolation tests make that separation enforceable.&lt;/p&gt;

&lt;p&gt;That’s the sweet spot for BootSaaS. I’m willing to manage versioned tenant schemas to get this boundary, and I don’t want the extra database topology as the default price of onboarding a customer.&lt;/p&gt;

&lt;p&gt;Here’s what that separation changes for an application query. With separate schemas, the query can stay simple:&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;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;amount&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;invoices&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the connection resolves invoices to acme.invoices, that query reads Acme’s table. Globex’s rows are in another table. Omitting a tenant filter no longer combines the two datasets in this example.&lt;/p&gt;

&lt;p&gt;PostgreSQL uses search_path to resolve unqualified table names. However, schemas are namespaces, not automatic security walls: a database role with sufficient privileges can explicitly query another schema. &lt;a href="https://www.postgresql.org/docs/current/ddl-schemas.html" rel="noopener noreferrer"&gt;PostgreSQL’s schema documentation&lt;/a&gt; explains both mechanisms.&lt;/p&gt;

&lt;p&gt;This is the boundary I want to enforce: establish the authorized tenant context before business queries run. I can concentrate that responsibility in the connection and persistence infrastructure, then test it explicitly.&lt;/p&gt;

&lt;p&gt;That infrastructure must be correct. An Acme request routed to Globex’s schema can still read the wrong invoices. Tenant authorization and connection handling are requirements of this design, and I treat them as such.&lt;/p&gt;

&lt;h2&gt;
  
  
  Persistence, migrations, and tenant provisioning have different jobs
&lt;/h2&gt;

&lt;p&gt;Hibernate handles entity persistence and querying. Database migrations describe and version changes to the database structure. The application coordinates tenant creation and decides which tenant a request belongs to.&lt;/p&gt;

&lt;p&gt;For BootSaaS, I use Liquibase to run and track those migrations. Flyway is another option for that role; schema-per-tenant does not depend on Liquibase. The following implementation details reflect the tool I chose.&lt;/p&gt;

&lt;p&gt;Adding Liquibase doesn’t automatically create a tenant onboarding system. Spring Boot’s standard integration runs migrations at startup; provisioning schemas when workspaces are created requires additional application logic. Spring Boot documents the startup integration &lt;a href="https://docs.spring.io/spring-boot/how-to/data-initialization.html" rel="noopener noreferrer"&gt;here&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Liquibase changelogs contain changesets and can use XML, YAML, JSON, or formatted SQL. XML and YAML can describe changes such as creating a table; they can also reference SQL files. They aren’t simply containers you must put SQL into. &lt;a href="https://docs.liquibase.com/community/user-guide-5-0-4/what-is-a-changelog" rel="noopener noreferrer"&gt;Liquibase’s changelog documentation&lt;/a&gt; includes examples.&lt;/p&gt;

&lt;p&gt;Creating or changing a JPA entity doesn’t, by itself, write the corresponding migration. The mapping and the migration both need to describe the intended database structure.&lt;/p&gt;

&lt;h3&gt;
  
  
  About ddl-auto=none
&lt;/h3&gt;

&lt;p&gt;When Liquibase owns schema changes, Hibernate shouldn’t also create or update those tables. One configuration choice is:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;spring.jpa.hibernate.ddl-auto=none&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;But validate is different from update or create: it checks the schema without changing it. It isn’t inherently incompatible with Liquibase. With dynamically provisioned tenants, you need to decide which schema exists at startup and what Hibernate can actually validate. A successful startup check is not evidence that every tenant schema is up to date. Hibernate defines these actions &lt;a href="https://docs.hibernate.org/orm/7.1/javadocs/org/hibernate/tool/schema/Action.html" rel="noopener noreferrer"&gt;here&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Creating a workspace: provision first, activate second
&lt;/h2&gt;

&lt;p&gt;I use asynchronous tenant provisioning in BootSaaS. The rule is straightforward: a workspace must not accept business requests until its schema is ready. The lifecycle should enforce that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Register the new tenant in a shared tenant registry, with a provisioning status.&lt;/li&gt;
&lt;li&gt;Allocate an internal schema name and create the schema.&lt;/li&gt;
&lt;li&gt;Run the tenant changelog against that schema.&lt;/li&gt;
&lt;li&gt;Mark the tenant active only after provisioning succeeds.&lt;/li&gt;
&lt;li&gt;Record failures so provisioning can be investigated and retried safely.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A shared schema can hold the tenant registry and other platform-wide records. Tenant schemas hold the business data that belongs to each workspace. Keep platform migrations and tenant migrations separate so a new workspace doesn’t receive copies of tables intended to be global.&lt;/p&gt;

&lt;p&gt;Schema names should come from an application-controlled mapping, not raw user input inserted into SQL.&lt;/p&gt;

&lt;p&gt;The migration definition is reusable, but its execution must target each tenant separately. A straightforward design is to keep each tenant’s migration history and lock tables in its own schema. Liquibase distinguishes the default target schema from the schema holding &lt;code&gt;DATABASECHANGELOG&lt;/code&gt; and &lt;code&gt;DATABASECHANGELOGLOCK;&lt;/code&gt; configure both deliberately. See the &lt;a href="https://docs.liquibase.com/secure/reference-guide-5-1-1/parameters/default-schema-name" rel="noopener noreferrer"&gt;default schema parameter&lt;/a&gt; and the &lt;a href="https://docs.liquibase.com/secure/reference-guide-5-2-2/parameters/liquibase-schema-name" rel="noopener noreferrer"&gt;Liquibase metadata schema parameter&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;This matters because Liquibase identifies applied changesets using their ID, author, and file path. Reusing one undifferentiated migration history for every tenant can make a changeset applied to one schema appear already applied for the next. Liquibase describes that tracking &lt;a href="https://docs.liquibase.com/secure/user-guide-5-2-2/what-is-the-databasechangelog-table" rel="noopener noreferrer"&gt;here&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Retries also deserve attention. If an attempt created the schema but failed later, restarting provisioning must account for that partial state. Running work asynchronously doesn’t make it automatically recoverable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Handling a request: resolve, authorize, then query
&lt;/h2&gt;

&lt;p&gt;Creating separate schemas is only half the implementation. Every request also needs to reach the correct one.&lt;/p&gt;

&lt;p&gt;In BootSaaS, the source of the tenant identifier depends on whether the request is authenticated.&lt;/p&gt;

&lt;p&gt;For public routes such as login or registration, the &lt;code&gt;tenantId&lt;/code&gt; comes directly from the request URL. It identifies the tenant the request is targeting. That value is routing information: putting a tenant ID in a URL does not grant access to its data.&lt;/p&gt;

&lt;p&gt;Once the user is authenticated, the &lt;code&gt;tenantId&lt;/code&gt; is included directly in the JWT payload alongside the other authentication claims. The token’s signature protects those claims against undetected modification; it does not make the payload confidential. The JWT must be validated before its claims are trusted. Spring Security documents JWT validation &lt;a href="https://docs.spring.io/spring-security/reference/servlet/oauth2/resource-server/jwt.html" rel="noopener noreferrer"&gt;here&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;For each authenticated request, a filter in the Spring Security chain extracts the &lt;code&gt;tenantId&lt;/code&gt; from the validated JWT and places it in the tenant context. From there:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;CurrentTenantIdentifierResolver&lt;/code&gt; supplies the current tenant identifier to Hibernate.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;MultiTenantConnectionProvider&lt;/code&gt; provides a connection configured for the corresponding PostgreSQL schema.&lt;/li&gt;
&lt;li&gt;Business queries execute against that tenant’s tables.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The tenant context must be established before Hibernate opens the tenant-scoped session. It must also be cleared after the request so that a reused application thread cannot carry the previous request’s tenant into another one.&lt;/p&gt;

&lt;p&gt;That’s what I like about this design: tenant routing follows the authenticated identity through Spring Security and into Hibernate. The authentication flow stays stateless, with no server-side authentication session required to remember the selected tenant.&lt;/p&gt;

&lt;p&gt;For BootSaaS, this is a clean fit. I establish the tenant context at the request boundary, and the persistence infrastructure uses it consistently to select the right schema.&lt;/p&gt;

&lt;h2&gt;
  
  
  Existing tenants need migrations too
&lt;/h2&gt;

&lt;p&gt;Creating the first tables is only the beginning. When a release adds a column, every existing tenant eventually needs that migration.&lt;/p&gt;

&lt;p&gt;Whether each tenant has a separate schema or a separate database, the change must be applied to each tenant’s tables and its outcome tracked. Choosing schema-per-tenant simplifies the connection infrastructure; it does not eliminate the repeated migration work.&lt;/p&gt;

&lt;p&gt;If migrating one tenant took five seconds, updating 2,000 tenants sequentially would take approximately 2 hours 47 minutes. That’s illustrative arithmetic, not a benchmark for a particular migration tool. It applies to either layout under that assumption. Put that sequence in your deployment pipeline, and your release waits for it.&lt;/p&gt;

&lt;p&gt;That requires orchestration: enumerate tenants, track outcomes, limit concurrent migrations, and handle failures. If schemas are upgraded gradually, the application needs to tolerate the supported versions during that window, or access must be coordinated with the upgrade.&lt;/p&gt;

&lt;p&gt;Keep the tenant structure consistent by default. Separate schemas allow customization, but maintaining a different set of columns for each customer introduces migration branches, mapping differences, and more combinations to test. For a standard SaaS, that undermines the maintainability this design is meant to give you.&lt;/p&gt;

&lt;p&gt;At large tenant counts, schema and table growth still need measurement, and the instance remains a shared resource and failure domain. I accept those constraints for BootSaaS. A common pool and separate business tables are useful benefits now; the migration strategy must remain explicit as the product grows.&lt;/p&gt;

&lt;h2&gt;
  
  
  The isolation tests I would include
&lt;/h2&gt;

&lt;p&gt;A useful test suite needs at least two tenants with different data. I would include:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A list or export request for Acme that must never return Globex’s records.&lt;/li&gt;
&lt;li&gt;A request using a resource ID belonging only to Globex, which Acme must not read or modify.&lt;/li&gt;
&lt;li&gt;The same resource ID in both schemas, verifying that each tenant gets its own record.&lt;/li&gt;
&lt;li&gt;An unauthorized workspace selection, rejected before business data is accessed.&lt;/li&gt;
&lt;li&gt;Alternating requests through a deliberately small connection pool, including an exception path, to exercise connection reuse.&lt;/li&gt;
&lt;li&gt;A failed provisioning attempt, verifying that the workspace stays unavailable and recovery doesn’t corrupt its state.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These tests verify the boundaries that the architecture depends on.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this is my default for BootSaaS
&lt;/h2&gt;

&lt;p&gt;For BootSaaS, schema-per-tenant is a deliberate engineering choice. I want separate business tables per workspace, reusable database connections, and a common set of versioned migrations that the application can apply as tenants are provisioned and upgraded.&lt;/p&gt;

&lt;p&gt;I consider the extra database topology of database-per-tenant a poor default for that workload. Schema-per-tenant gives the team a concrete boundary to enforce while keeping the connection infrastructure shared.&lt;/p&gt;

&lt;p&gt;Authorized tenant selection, correct connection handling, recoverable provisioning, and isolation tests are the implementation work that makes this choice deliver. That’s where I want to invest the engineering effort.&lt;/p&gt;

&lt;p&gt;I’m building &lt;a href="https://bootsaas.dev/" rel="noopener noreferrer"&gt;BootSaaS&lt;/a&gt;, a Spring Boot and Angular boilerplate that includes schema-per-tenant PostgreSQL storage and asynchronous provisioning with Liquibase. If you’re considering the same foundation for a project, you can find more details there.&lt;/p&gt;

</description>
      <category>springboot</category>
      <category>database</category>
      <category>saas</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Why You Should Avoid @Data on JPA Entities</title>
      <dc:creator>BootSaaS</dc:creator>
      <pubDate>Wed, 26 Aug 2026 22:15:49 +0000</pubDate>
      <link>https://dev.to/bootsaas/why-you-should-avoid-data-on-jpa-entities-1kd8</link>
      <guid>https://dev.to/bootsaas/why-you-should-avoid-data-on-jpa-entities-1kd8</guid>
      <description>&lt;p&gt;Lombok isn't necessarily the problem. Not understanding what it generates is.&lt;/p&gt;

&lt;p&gt;Under the hood, &lt;code&gt;@Data&lt;/code&gt; is essentially a combination of several Lombok annotations: &lt;code&gt;@Getter&lt;/code&gt;, &lt;code&gt;@Setter&lt;/code&gt;, &lt;code&gt;@ToString&lt;/code&gt;, &lt;code&gt;@EqualsAndHashCode&lt;/code&gt;, and &lt;code&gt;@RequiredArgsConstructor&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;@Getter&lt;/code&gt;, &lt;code&gt;@Setter&lt;/code&gt;, and &lt;code&gt;@RequiredArgsConstructor&lt;/code&gt; are generally not the source of this particular problem. The dangerous ones here are &lt;code&gt;@ToString&lt;/code&gt; and &lt;code&gt;@EqualsAndHashCode&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;In a bidirectional relationship (&lt;code&gt;@​OneToMany&lt;/code&gt; / &lt;code&gt;@​ManyToOne&lt;/code&gt;), entities reference each other.&lt;/p&gt;

&lt;p&gt;Let's use this code as an example:&lt;/p&gt;

&lt;p&gt;On one side:&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="nd"&gt;@Entity&lt;/span&gt;
&lt;span class="nd"&gt;@Data&lt;/span&gt;
&lt;span class="nd"&gt;@Table&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"user"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;User&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nd"&gt;@OneToMany&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mappedBy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"owner"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;private&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;House&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;houses&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And on the other side:&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="nd"&gt;@Entity&lt;/span&gt;
&lt;span class="nd"&gt;@Data&lt;/span&gt;
&lt;span class="nd"&gt;@Table&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"house"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;House&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nd"&gt;@ManyToOne&lt;/span&gt;
    &lt;span class="nd"&gt;@JoinColumn&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"user_id"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;nullable&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="nc"&gt;User&lt;/span&gt; &lt;span class="n"&gt;owner&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is a bidirectional relationship. The user has houses and knows them, and each house knows who owns it.&lt;/p&gt;

&lt;p&gt;Here's the trap: this happened to me on an enterprise project. The particularly frustrating part was that I wasn't even trying to access the user's houses, I was just logging in. With &lt;code&gt;DEBUG&lt;/code&gt; logging enabled locally, one of the objects involved in the authentication flow was being logged. Because &lt;code&gt;@Data&lt;/code&gt; generates a &lt;code&gt;toString()&lt;/code&gt; method, logging the entity caused Lombok to traverse its fields. The User contained a list of House entities, and each House contained a reference back to its User. That created a recursive &lt;code&gt;toString()&lt;/code&gt; call, an endless cycle where parent calls child and child calls parent. This eventually results in a StackOverflowError.&lt;/p&gt;

&lt;p&gt;Locally, the application ran with &lt;code&gt;DEBUG&lt;/code&gt; logging and crashed. In Docker, logging was set to &lt;code&gt;WARN&lt;/code&gt; and everything worked. That difference eventually led me to the real culprit: Lombok's generated &lt;code&gt;toString()&lt;/code&gt; combined with a bidirectional JPA relationship.&lt;/p&gt;

&lt;p&gt;To fix this, I had to add the &lt;code&gt;@ToString.Exclude&lt;/code&gt; and &lt;code&gt;@EqualsAndHashCode.Exclude&lt;/code&gt; annotations to the child side of the relationship.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;@ToString.Exclude&lt;/code&gt; prevents Lombok from including owner when generating &lt;code&gt;toString()&lt;/code&gt;.&lt;br&gt;
&lt;code&gt;@EqualsAndHashCode.Exclude&lt;/code&gt; does something similar for &lt;code&gt;equals()&lt;/code&gt; and &lt;code&gt;hashCode()&lt;/code&gt;: it tells Lombok not to use that field when determining whether two entities are equal or when calculating their hash code.&lt;/p&gt;

&lt;p&gt;This is not only about preventing recursion. With JPA entities, using relationships in &lt;code&gt;equals()&lt;/code&gt; and &lt;code&gt;hashCode()&lt;/code&gt; can also be problematic because those relationships are mutable and managed by Hibernate. An entity's equality should not unexpectedly change just because one of its associations changed.&lt;/p&gt;

&lt;p&gt;For that reason, excluding relationships from Lombok-generated &lt;code&gt;equals()&lt;/code&gt; and &lt;code&gt;hashCode()&lt;/code&gt; is generally a much safer default.&lt;/p&gt;

&lt;p&gt;I also want to discuss another trap: Inheritance. If your entity extends a base class (like an AuditingEntity), &lt;code&gt;@​Data&lt;/code&gt; can cause hidden bugs. You should explicitly decide how &lt;code&gt;@EqualsAndHashCode&lt;/code&gt; should handle the parent class fields.&lt;/p&gt;

&lt;p&gt;Here's a short example:&lt;/p&gt;

&lt;p&gt;Let's say you have an AuditingEntity so you don't need to rewrite your timestamps and your User class extends it now:&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="nd"&gt;@MappedSuperclass&lt;/span&gt;
&lt;span class="nd"&gt;@EntityListeners&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;AuditingEntityListener&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="nd"&gt;@SQLRestriction&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"deleted_at IS NULL"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;abstract&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;AuditingEntity&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nd"&gt;@CreatedDate&lt;/span&gt;
    &lt;span class="nd"&gt;@Column&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"created_at"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;&lt;span class="n"&gt;nullable&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;&lt;span class="n"&gt;updatable&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="nc"&gt;Instant&lt;/span&gt; &lt;span class="n"&gt;createdAt&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="nd"&gt;@LastModifiedDate&lt;/span&gt;
    &lt;span class="nd"&gt;@Column&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"updated_at"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="nc"&gt;Instant&lt;/span&gt; &lt;span class="n"&gt;updatedAt&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="nd"&gt;@Column&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"deleted_at"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="nc"&gt;Instant&lt;/span&gt; &lt;span class="n"&gt;deletedAt&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Instead of this User class,&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="nd"&gt;@Entity&lt;/span&gt;
&lt;span class="nd"&gt;@Data&lt;/span&gt;
&lt;span class="nd"&gt;@Table&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"user"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;User&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;AuditingEntity&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nd"&gt;@OneToMany&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mappedBy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"owner"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;private&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;House&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;houses&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I'd rather have this:&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="nd"&gt;@Entity&lt;/span&gt;
&lt;span class="nd"&gt;@Data&lt;/span&gt;
&lt;span class="err"&gt;@​&lt;/span&gt;&lt;span class="nc"&gt;EqualsAndHashCode&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;callSuper&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;User&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;AuditingEntity&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nd"&gt;@OneToMany&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mappedBy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"owner"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;private&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;House&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;houses&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When a class extends another class, Lombok requires you to make an explicit choice about whether the superclass should participate in &lt;code&gt;equals()&lt;/code&gt; and &lt;code&gt;hashCode()&lt;/code&gt;. Setting &lt;code&gt;callSuper = false&lt;/code&gt; tells Lombok not to call the superclass (AuditingEntity here) implementations.&lt;/p&gt;

&lt;p&gt;This is particularly useful when the parent class contains technical or auditing fields such as &lt;code&gt;createdAt&lt;/code&gt;, &lt;code&gt;updatedAt&lt;/code&gt;, or &lt;code&gt;deletedAt&lt;/code&gt;. If the superclass fields should not participate in equality, make that choice explicit with &lt;code&gt;callSuper = false&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;At the end of the day, if you have to use &lt;code&gt;@Data&lt;/code&gt;, make sure you understand how to handle its generated methods. Otherwise, I highly recommend using only the Lombok annotations you actually need. Therefore, I would prefer having a User class that looks like this:&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="nd"&gt;@Entity&lt;/span&gt;
&lt;span class="nd"&gt;@Table&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"user"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="nd"&gt;@Getter&lt;/span&gt;
&lt;span class="nd"&gt;@Setter&lt;/span&gt;
&lt;span class="nd"&gt;@NoArgsConstructor&lt;/span&gt;
&lt;span class="err"&gt;@​&lt;/span&gt;&lt;span class="nc"&gt;EqualsAndHashCode&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;callSuper&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;User&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;AuditingEntity&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nd"&gt;@OneToMany&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mappedBy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"owner"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;private&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;House&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;houses&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Explicit annotations make the generated behavior visible at the class level. &lt;code&gt;@Data&lt;/code&gt; is convenient, but convenience can hide important behavior. Only generate what you actually need.&lt;/p&gt;

&lt;p&gt;Always remember that boilerplate reducers are meant to save time, not to replace architectural thinking. Always know what your annotations compile into. Build clean!&lt;/p&gt;

</description>
      <category>springboot</category>
      <category>java</category>
      <category>softwareengineering</category>
      <category>coding</category>
    </item>
  </channel>
</rss>
