CodeGym /Courses /Spring Security /The boundary between UserAccount and UserProfile

The boundary between UserAccount and UserProfile

Spring Security
Level 14, Lesson 2
Available

1. The “User with everything at once” problem in security

When we start storing users in the DB, beginners often get a very natural urge: “Let’s make one User class, put everything in it — the account, the profile, the avatar, the roles, and basically everything we can find on the internet under the word user.” It feels a bit like stuffing all the tools into one huge drawer with no dividers: yes, formally everything is “in one place,” but every time you reach in, you pull out the wrong thing, and sooner or later you will cut yourself.

The main problem with a “universal User” in a security project is not even code aesthetics. The issue is that security data is almost always sensitive, while profile data almost always needs to be easy to return and update through the API. If you mix them in one entity, you will either start leaking extra data by accident, or you will build endless @JsonIgnore rules, or, most often, one day someone will “quickly return the entity from the controller,” and you will get... a surprise.

Let’s look at a typical anti-example. It is not bad because “the clean code police says so”, but because it invites direct vulnerabilities, like leaks and unauthorized changes.

package com.example.securecontent.user;

import java.util.Set;

public class User {
    private Long id;
    private String username;
    private String email;

    // These fields should almost never be exposed through the API:
    private String passwordHash; // oops: even a password hash is sensitive data
    private boolean enabled;     // oops: the client must not control the account status
    private Set<String> roles;   // oops: roles are part of the security core

    // And these are the “showcase” profile fields:
    private String displayName;  // profile: what we show to people
    private String bio;          // profile: description/about me
    private String avatarPath;   // profile: path to the avatar file (not security data)
}

Now let’s add a “convenient” controller to that. It looks cute until you remember that “convenient” and “secure” are not always friends.

package com.example.securecontent.user;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class MeController {

    @GetMapping("/api/me")
    public User me() {
        // Dangerous habit: “return the entity/model as is.”
        // As soon as a real user from the DB appears here, you will start returning extra fields.
        return new User(); // placeholder, but in reality someone would return an entity
    }
}

Even if User is empty right now, because this is a skeleton, the architecture itself already invites mistakes. In a week, you will plug in JPA, start returning the “real user,” and suddenly the client will receive fields it should never see. And yes, sometimes you might get “lucky” and serialization won’t include a field, but in security you cannot make bets like that: “maybe” is not a strategy.

The second pain point is updates. If you accept User in PATCH /api/me/profile, you are basically telling the client: “Here is an object, try sending me back any fields you want, and we will see what happens.” At best, you will be filtering forever; at worst, one day something in the security core will update too, for example enabled, and you will only notice it in production. The third pain point is maintenance: such a class becomes a magnet for new fields, and after a month it contains everything, including the developer’s dreams and pain.

2. UserAccount and UserProfile: two entities

To avoid turning the project into mush and to keep security from becoming fragile, we introduce a simple boundary up front: the account (UserAccount) and the profile (UserProfile) are different entities, even if the API sometimes returns them together. If you want an image: UserAccount is the “passport for signing in,” and UserProfile is the user’s “business card/questionnaire,” which they fill out for other people (or for themselves).

This boundary solves several practical tasks at once. First, it reduces the risk of leaking security data through the API. Second, it makes update flows safer: you can change the profile without touching the password, roles, or account status. Third, it helps the project structure: the security part stays compact, while the profile evolves separately without overloading the authentication/authorization model.

It is convenient to record the differences in a small table. The important thing is not to memorize “like in the textbook,” but to catch the intuition: these are different system questions.

Question Answered by UserAccount Answered by UserProfile
“Who are you?” (identification) Yes (username, email) No
“Can we let you in?” Yes (enabled, accountNonLocked) No
“What are you allowed to do?” Yes (roles) No
“How should we show you in the UI?” Partially (sometimes username) Yes (displayName, bio, avatarPath)
“What does the user change themselves in settings?” Rarely (usually only the password, and even that separately) Yes (almost everything)
“Which data must not be returned in the API by accident?” Very much (hash, statuses, roles) Almost none (but we still filter)

Now let’s look at the minimal skeletons. UserAccount was already shown in the previous lecture, but here the focus is different: this is the security core.

package com.example.securecontent.security.account;

import java.util.Set;

public class UserAccount {
    private Long id;

    // User identification (what they “are” in the system)
    private String username;
    private String email;

    // Security core: these fields must not be returned or accepted through ordinary profile APIs
    private String passwordHash;
    private boolean enabled;
    private boolean accountNonLocked;

    // Access rights: this is not a “profile,” but part of authorization
    private Set<Role> roles; // details of role storage are a separate topic
}

And the profile is application-specific data. It can be empty, it can evolve, but it must not drag in the password, the roles, or the “do not allow login” flag.

package com.example.securecontent.profile;

public class UserProfile {
    // Connection to the account: the profile is built on top of UserAccount by its id
    private Long userId;       // reference to the account

    // What we show/edit in the UI
    private String displayName;
    private String bio;
    private String avatarPath; // path to the avatar file
}

I also want to emphasize an important thought for beginners: “What if I need /api/me, where I have both username and displayName?” — that is a normal need, and it is solved at the DTO level, not by mixing storage models.

3. Minimal UserProfile: what to store

When we first separated UserProfile, it is tempting to start “improving the product”: add birth date, social links, privacy settings, a favorite background color, and a list of favorite memes. That is emotionally understandable, but dangerous for the current course: we are in the security track, and we need the profile to be minimal exactly as much as it helps us demonstrate protected endpoints, owner-only scenarios, and file upload (avatar). So we keep UserProfile minimal and boring — boring models usually break less often.

The minimal profile in our project is what the user really sees and changes as “data about me,” and what is convenient to show in /api/me/profile. In our case that is displayName, bio, and avatarPath. The key point is userId, which links the profile to the account. And userId is not “just another id,” but a bridge between two worlds: the security-account world and the application-profile world.

The skeleton can stay as short as possible, and that is perfectly fine:

package com.example.securecontent.profile;

public class UserProfile {
    // Account id that owns this profile
    private Long userId;

    // Public/semi-public profile fields
    private String displayName;
    private String bio;

    // We store a reference/path, not binary file data
    private String avatarPath;
}

The invariant here is simple (and very useful for the brain): a profile belongs to exactly one account. On the mental model level: if there is a UserAccount with id = 10, then the profile for it either exists (userId = 10) or has not been created yet, but it cannot be “a profile for some other account.” It may seem obvious, but obvious things are exactly what later save us from weird bugs.

Another practical benefit of the separation is that you can update the profile as often as you like, and even add new fields, without changing your security core. In other words, the security layer keeps thinking in categories like: “id, login, password, roles, enabled/locked,” while the profile evolves as a normal application feature.

4. DTO/VIEW: how to assemble the /api/me response

A very common source of confusion for beginners sounds like this: “Since our /api/me returns both username and displayName, then we should store them in one object.” That is “how do I render the screen” logic instead of “how do I safely store and modify data” logic. In backend development, these two logics should meet at the boundary of presentation: in a DTO, view model, assembler, mapper — call it whatever you want, the essence is the same: we assemble the response from different sources, but we do not mix the sources.

The /api/me response schema may look like this: we take some data from the account, some from the profile, and then return exactly what the client needs. No passwordHash, no enabled, no roles (if you do not want to show them). For a beginner, this is a great habit: do not return entities to the outside world, but create a clear contract.

For example, a DTO for “what the client sees in the /api/me area”:

package com.example.securecontent.profile.api;

public class MeView {
    // What can be shown to the client for identification in the UI
    private String username;
    private String email;

    // Profile part: the user’s showcase
    private String displayName;
    private String bio;
    private String avatarPath;
}

And now a small mapper. Yes, it is tempting to “not write it because it is obvious anyway.” But in a real project, classes like this are exactly what save you from leaks and from chaos in controllers.

package com.example.securecontent.profile.api;

import com.example.securecontent.profile.UserProfile;
import com.example.securecontent.security.account.UserAccount;

public class MeViewMapper {

    public MeView toView(UserAccount account, UserProfile profile) {
        // Important: here we consciously choose which fields will go into the API response.
        // Anything we did not set manually will not “accidentally” leak outside.
        MeView v = new MeView();

        // Take only safe and necessary data from the account
        v.setUsername(account.getUsername());
        v.setEmail(account.getEmail());

        // Take profile fields from the profile
        v.setDisplayName(profile.getDisplayName());
        v.setBio(profile.getBio());
        v.setAvatarPath(profile.getAvatarPath());

        return v;
    }
}

Notice that the mapper does not have access to passwordHash at all. Even if UserAccount contains it, the mapper simply does not use that field. Psychologically, this is an important barrier: “to leak, you must explicitly want to leak.”

Now the service that builds MeView. Here I intentionally use a method with the username parameter, although in real life we would get the current user from the security context. The nuance of “how to get the current user” is a separate topic, and we do not need to jump into it now.

package com.example.securecontent.profile;

import com.example.securecontent.profile.api.MeView;
import com.example.securecontent.profile.api.MeViewMapper;
import com.example.securecontent.security.account.UserAccount;

public class MeService {

    private final UserAccountRepository accountRepository;
    private final UserProfileRepository profileRepository;
    private final MeViewMapper mapper;

    public MeView getMe(String username) {
        // 1) First we find the account by “login” (simplified)
        UserAccount acc = accountRepository.getByUsername(username);

        // 2) Then we find the profile by the account id
        UserProfile prof = profileRepository.getByUserId(acc.getId());

        // 3) Only then do we assemble the DTO for the outward response
        return mapper.toView(acc, prof);
    }
}

And finally, the controller. It stays “thin”: it does not assemble the object manually, does not spread profile retrieval logic throughout the code, and does not think about what is stored where.

package com.example.securecontent.profile.api;

import com.example.securecontent.profile.MeService;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class MeController {

    private final MeService meService;

    public MeController(MeService meService) {
        this.meService = meService;
    }

    @GetMapping("/api/me")
    public MeView me() {
        // The current user should come from the security context here.
        // In the lecture, we leave a placeholder so as not to mix topics.
        return meService.getMe("user"); // placeholder: current username
    }
}

This looks a little more “verbose” than “return the entity.” But that verbosity buys you security and predictability. And honestly, in a security course, that is one of the best deals you can make.

To lock the picture into your head, here is a small diagram of how /api/me is assembled from two parts:

flowchart TD
  Client["Client: GET /api/me"] --> Controller["MeController"]
  Controller --> Service["MeService"]
  Service --> AccRepo["UserAccountRepository"]
  Service --> ProfRepo["UserProfileRepository"]
  AccRepo --> Account["UserAccount (security core)"]
  ProfRepo --> Profile["UserProfile (application profile)"]
  Account --> Mapper["MeViewMapper"]
  Profile --> Mapper
  Mapper --> DTO["MeView (DTO for response)"]
  DTO --> Client

If this feels like “too many layers for a training project,” remember: we are not building a CRUD course, we are building a course where security must remain clear and stable. Separating account/profile is one of the cheapest ways to achieve that.

5. Updating the profile: request DTO instead of entity

The next typical trap looks like this: “Since the profile is user data, let it accept a UserProfile object in PATCH /api/me/profile.” It seems logical, but there is a catch: as soon as you start adding new fields to UserProfile, you begin to expand the API contract by accident. And if you ever make a mistake and accept UserAccount or a “universal User,” you open the door to role and status updates.

So the safe approach is this: for each update operation, use its own request DTO, containing only what is allowed to change in that operation. It sounds “formal,” but in practice it is one of the most reliable patterns.

For example, a DTO for updating the profile:

package com.example.securecontent.profile.api;

public class UpdateMyProfileRequest {
    // Only what the user is allowed to edit in this operation
    private String displayName;
    private String bio;
}

The controller accepts exactly this DTO. It does not know and should not know anything about roles, enabled, or passwordHash. It simply receives what the user is allowed to change.

package com.example.securecontent.profile.api;

import com.example.securecontent.profile.ProfileService;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class MyProfileController {

    private final ProfileService profileService;

    public MyProfileController(ProfileService profileService) {
        this.profileService = profileService;
    }

    @PatchMapping("/api/me/profile")
    public void update(@RequestBody UpdateMyProfileRequest req) {
        profileService.updateProfile(10L, req); // placeholder: current user id
    }
}

And the service that actually changes the profile. Notice that this change affects UserProfile, not UserAccount. This is our boundary in code: profile updates must not go through the account object.

package com.example.securecontent.profile;

import com.example.securecontent.profile.api.UpdateMyProfileRequest;

public class ProfileService {

    private final UserProfileRepository profileRepository;

    public void updateProfile(Long userId, UpdateMyProfileRequest req) {
        UserProfile profile = profileRepository.getByUserId(userId);
        profile.setDisplayName(req.getDisplayName());
        profile.setBio(req.getBio());
        profileRepository.save(profile);
    }
}

Yes, there are “placeholders” here too (like how to get userId), and we are intentionally leaving that out of scope. Today our task is the data boundary, not how the security context is wired in. But even in this form, the main point is clear: the profile is updated in its own zone, and the security core is untouched.

6. Database relationship: account id and profile userId

When you move to the database, the relationship between account and profile has to be stored somehow. The simplest and clearest idea is this: UserAccount has an id, UserProfile has a userId, and they are the same numeric identifier. In other words, the profile is a kind of “extension” of the account. This is a very simple model: one account, one profile (or the profile may appear later), and it is perfect for a course where we do not want to drown in ORM acrobatics.

I will show the idea using simplified JPA entities. I will not go into the details of @OneToOne, @MapsId, and the rest here — not because it is “not needed in real life,” but because in this lecture the semantic boundary matters more than the beauty of the mapping. In a training project, a normal strategy is to start simple and transparent.

UserAccountEntity (simplified):

package com.example.securecontent.security.account;

import jakarta.persistence.Entity;
import jakarta.persistence.Id;

@Entity
public class UserAccountEntity {

    @Id
    private Long id;

    private String username;
    private String email;
}

UserProfileEntity:

package com.example.securecontent.profile;

import jakarta.persistence.Entity;
import jakarta.persistence.Id;

@Entity
public class UserProfileEntity {

    @Id
    private Long userId;

    private String displayName;
    private String avatarPath;
}

Yes, this looks “too minimal.” And that is great. Because a minimal model holds boundaries better. The key thing is to understand that userId in the profile points to the account and does not live its own life. At the database level this is usually implemented as a foreign key, but even without that you have already formed the correct mental model.

Another plus of this relationship is that you can safely develop the profile as a separate feature. Want to add bio? Add it to UserProfile. Want to add createdAt to the account? Add it to UserAccount. And that does not lead to endless conflicts over “who should update the same table when.”

7. Typical mistakes when separating account/profile

Mistake #1: no DTO at the API boundary.
A very common situation: you made UserAccount and UserProfile, nice work, and then you returned UserAccount from the controller “because it is faster.” Formally the classes are different, but the problem is not solved: passwordHash, roles, or account state flags can still leak outside. The fix is simple: at the API boundary, use DTOs (MeView, ProfileView) and a mapper.

Mistake #2: updating the profile through the account object.
Sometimes a developer makes PATCH /api/me and accepts UserAccount, because “it is data about me.” Then they start “only changing displayName,” but the object also contains roles, statuses, and password — and from there it is just a matter of technique: someone forgets a filter or uses BeanUtils.copyProperties, and suddenly security fields are updated from client input. From a security point of view, that is a very bad surprise. The right path is a separate request DTO for profile updates.

Mistake #3: mixed responsibility in names and packages.
Even if you separated the entities correctly, but put everything into one package like com.example.securecontent.user, the brain will start getting confused: where is security, where is profile, where is API. In a couple of weeks any new class will “accidentally” depend on everything. It is much easier to maintain the boundary when UserAccount lives closer to the security/auth/admin zones, and UserProfile lives in the profile feature. This is not a dogma, but it is a good training rail.

Mistake #4: the profile turns into a dump for security logic.
Sometimes fields like lockedReason, failedLoginAttempts, rolesText, and other things that actually belong to security, not to the profile, start appearing in UserProfile. Then you blur the boundary again, just under a different name. A good sanity check is this: if a field affects “can I log in?” or “what can I do?” — then it almost certainly belongs to UserAccount, not UserProfile.

Mistake #5: duplicating data in the account and profile.
For example, email lives both in the account and in the profile “just in case.” At first it seems convenient, but then you get inconsistency: where is the source of truth? Which field should be updated? What should be returned in /api/me? Try to make sure that every field has one “correct place.” For our course: email and username live in UserAccount, while human-oriented fields live in UserProfile.

1
Task
Spring Security, level 14, lesson 2
Locked
Secure response `/api/me`
Secure response `/api/me`
1
Task
Spring Security, level 14, lesson 2
Locked
Profile Update Without Changing the Account
Profile Update Without Changing the Account
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION