Andres Bejarano
Skip to article

ArticleJava

My journey building a full-stack Spring Boot & AI product

From a failed no-code experiment to a full-stack Spring Boot application with enterprise JJWT authentication, multimodal AI pipelines, and automated AWS CI/CD quality gates.

Workspace with a healthy bowl, a laptop showing Java and Spring Boot, and a notebook of nutrition algorithms
19 minReady to listen

I never planned to “learn Java.” I just wanted a calorie and macro tracker that didn't feel like a chore.

Like many product designers, I had grown frustrated with standard nutrition apps. Logging every single ingredient through tedious multi-step dropdowns was so exhausting that I always ended up quitting after a week. I wanted something effortless: snap a photo of my meal, send a quick voice note, or drop a casual sentence in natural language, and let an intelligent system do the heavy lifting while giving me a clean visual command center to review my day.

That vision became Tracki (NutritionTracker).

If you are curious about the visual evolution, the user research, and how I built the dual-glassmorphism UI, you can read the full Design Case Study & Design System. But here, I want to tell the raw, backstage engineering story: how a failed no-code attempt pushed me in January 2026 to learn backend architecture from scratch, and how I turned a simple prototype into a full-stack Spring Boot 3 + React product with enterprise JJWT security, multimodal AI pipelines, and automated CI/CD quality gates on AWS.

Why I left no-code: Designing the schema first

The problem: trapped in linear chat streams

My journey began with excitement. I built a quick MVP using a Telegram bot connected via n8n to generative AI models. On the surface, it felt magical: I could take a photo of a salad or type "grilled salmon with quinoa" and get nutritional numbers back in under ten seconds.

Then the honeymoon ended.

I immediately hit a brick wall with no-code tools. All my data was trapped inside a linear chat thread. There was no real database underneath, no way to track daily progress against weekly goals, no historical trend charts, and zero ability to edit, update, or delete meals after logging them. If I wanted to build a true command center — a dashboard where someone could review their daily macros, adjust portions, and see visual progress — no-code simply couldn't carry the weight.

The solution: a clean relational foundation

In January 2026, I made a decisive commitment: I sat down to learn Java 17, Spring Boot 3, Spring Data JPA, and PostgreSQL from the ground up.

When you come from no-code, you are used to treating data as loose, chaotic JSON blobs. In Java, I quickly learned that your schema is your contract. Before writing a single controller or service, I opened my notebook and mapped out the relational foundation: one User, and four collections that belong to that user.

One User owns Goal, Entry, FavoriteMeal, and DailyAiUsage rows

Every table had a clear, distinct purpose:

  • User: Identity, account metadata, and Telegram chat pairing.
  • Goal: Nutritional calorie and macro benchmarks.
  • Entry: Concrete daily meal intake events.
  • FavoriteMeal: Master preset recipes for 1-tap fast logging.
  • DailyAiUsage: Daily token quota tracking to prevent runaway API bills.
java
// Core persistence entities (simplified view)
@Entity
@Table(name = "users")
public class User {
    @Id
    @GeneratedValue(strategy = GenerationType.UUID)
    private UUID id;
    
    @Column(nullable = false, unique = true)
    private String email;
    
    private String name;
 
    @Column(name = "telegram_chat_id", unique = true)
    private Long telegramChatId;
}
 
@Entity
@Table(name = "goals")
public class Goal {
    @Id
    @GeneratedValue(strategy = GenerationType.UUID)
    private UUID id;
 
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "user_id", nullable = false)
    private User user;
 
    private LocalDate startDate;
    private int kcal;
    private int protein;
    private int carbs;
    private int fat;
}
 
@Entity
@Table(name = "entries")
public class Entry {
    @Id
    @GeneratedValue(strategy = GenerationType.UUID)
    private UUID id;
 
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "user_id", nullable = false)
    private User user;
 
    private String mealName;
    private Instant loggedAt;
    private int kcal;
    private int protein;
    private int carbs;
    private int fat;
 
    @Enumerated(EnumType.STRING)
    private MealType mealType;
}

Seeing these tables materialize in PostgreSQL was a huge milestone for me. For the first time, my data had structure, relationships, and integrity.

API contracts: DTOs, Java Records, and MapStruct

The problem: entity leaks and boilerplate

As soon as my controllers started working, I made a classic beginner mistake: I returned JPA entities directly to the client.

It didn't take long to realize the problems:

  1. Accidental leaks: Returning entities exposed database primary keys, internal timestamps, and triggered circular lazy-loading exceptions during JSON serialization.
  2. Input tampering: Accepting entities directly in @PostMapping meant a malicious user could supply an arbitrary database id and overwrite existing records.
  3. Plumbing exhaustion: Manually writing classes with getters, setters, constructors, and manual mappings like entry.setKcal(dto.getKcal()) created hundreds of lines of fragile boilerplate.

The solution: records and MapStruct

Watching deep-dive architecture tutorials made the concept click: Entities should never cross the persistence boundary.

I separated every contract into distinct Request and Response DTOs using modern Java records:

  • Immutability by default: Every field in a record is private final. Once instantiated, payloads cannot be accidentally modified in flight.
  • Zero boilerplate: Automatic canonical constructors, clean accessors (dto.kcal()), equals(), and toString().
java
// Clean, immutable data contracts
public record EntryRequestDTO(
    @NotBlank(message = "Meal name is required")
    String mealName,
    @NotNull(message = "MealType is required")
    MealType mealType,
    int kcal,
    int protein,
    int carbs,
    int fat,
    Instant loggedAt
) {}
 
public record EntryResponseDTO(
    UUID id,
    String mealName,
    MealType mealType,
    int kcal,
    int protein,
    int carbs,
    int fat,
    Instant loggedAt
) {}

To eliminate manual mapping overhead, I introduced MapStruct. It generates high-performance mapping code at compile time:

java
@Mapper(componentModel = "spring", unmappedTargetPolicy = ReportingPolicy.IGNORE)
public interface EntryMapper {
 
    EntryResponseDTO toResponseDTO(Entry entity);
 
    List<EntryResponseDTO> toResponseDTOList(List<Entry> entities);
 
    @Mapping(target = "id", ignore = true)
    @Mapping(target = "user", ignore = true)
    Entry toEntity(EntryRequestDTO dto);
 
    @Mapping(target = "id", ignore = true)
    @Mapping(target = "user", ignore = true)
    void updateEntityFromDTO(EntryRequestDTO dto, @MappingTarget Entry entity);
}

Configuring unmappedTargetPolicy = ReportingPolicy.IGNORE and explicitly ignoring id and user during updates protected the database identity while keeping my service layer clean and readable.

REST controllers and HTTP semantics

The problem: undefined HTTP semantics

In the beginning, I treated API endpoints like generic functions that returned strings or defaulted everything to 200 OK. When the frontend encountered an error, debugging was painful because every response looked the same.

The solution: explicit controller contracts

I spent time studying REST best practices to give each endpoint clear HTTP semantics:

  • @PathVariable vs @RequestBody: Path variables identify specific resources (/api/entry/{id}), while the body carries the data payload.
  • ResponseEntity<T> status codes: Returning 200 OK for reads, 201 Created with the new resource for creations, and 204 No Content for successful deletions.
java
@RestController
@RequestMapping("/api/entry")
public class EntryController {
 
    private final EntryService entryService;
 
    public EntryController(EntryService entryService) {
        this.entryService = entryService;
    }
 
    @PostMapping("/{userId}")
    public ResponseEntity<EntryResponseDTO> create(
        @PathVariable UUID userId,
        @Valid @RequestBody EntryRequestDTO request
    ) {
        EntryResponseDTO created = entryService.createEntry(userId, request);
        return ResponseEntity.status(HttpStatus.CREATED).body(created);
    }
 
    @GetMapping("/{id}")
    public ResponseEntity<EntryResponseDTO> getById(
        @PathVariable UUID id,
        @AuthenticationPrincipal UserPrincipal principal
    ) {
        return ResponseEntity.ok(entryService.getEntryById(id, principal.getId()));
    }
}

Enterprise authentication: Decoupling IdP with JJWT

The problem: raw Google ID tokens in production and CI/CD

When I first integrated Google Login, I took what seemed like the easiest path: the React app sent Google's raw OAuth ID token in every single API request (Authorization: Bearer <google_id_token>), and my Spring filter verified that token against Google's public certificates on every request.

It worked on day one. But as the app grew, this architecture broke down completely:

  1. The 60-Minute Logout Trap: Google ID tokens have a strict 3600-second (exp) lifetime. Exactly 60 minutes after logging in, Google rejected the token, abruptly terminating user sessions mid-navigation with 401 Unauthorized.
  2. External Latency Penalty: Contacting Google's certificate endpoints over external HTTPS on every API call added an annoying 200–400ms latency penalty to every single click.
  3. CI/CD Automation Deadlock: Automated test runners in GitHub Actions (Newman/Postman) cannot click Google OAuth popups in headless environments, making continuous integration testing impossible.

The solution: OAuth2 Token Exchange & internal JJWT

The breakthrough was implementing the OAuth2 Token Exchange Pattern: decoupling identity verification from session management.

Login talks to Google once and mints a 30-day App JWT. Later API calls never touch Google.

  1. Google as IdP strictly at login: Google is contacted only once when the user taps "Sign in with Google" (POST /api/user/auth/google).
  2. Internal App JWT: Spring Boot verifies the Google token, retrieves or creates the User in PostgreSQL, and generates an internal, cryptographically signed Application JWT using JJWT (io.jsonwebtoken) with a 30-day lifetime.
  3. Sub-millisecond local auth: All subsequent REST calls use the internal JWT. Spring Boot verifies the HMAC-SHA256 signature locally in memory in under 0.1ms without touching Google's servers.
java
@Component
public class JwtTokenProvider {
 
    private final SecretKey key;
    private final long validityInMilliseconds;
 
    public JwtTokenProvider(
        @Value("${jwt.secret}") String secret,
        @Value("${jwt.expiration-ms:2592000000}") long validityInMilliseconds
    ) {
        byte[] keyBytes = secret.getBytes(StandardCharsets.UTF_8);
        this.key = Keys.hmacShaKeyFor(keyBytes);
        this.validityInMilliseconds = validityInMilliseconds;
    }
 
    public String generateToken(User user) {
        Date now = new Date();
        Date validity = new Date(now.getTime() + validityInMilliseconds);
 
        return Jwts.builder()
            .subject(user.getId().toString())
            .claim("email", user.getEmail())
            .claim("role", "ROLE_USER")
            .issuedAt(now)
            .expiration(validity)
            .signWith(key)
            .compact();
    }
 
    public String getUserIdFromToken(String token) {
        try {
            return Jwts.parser()
                .verifyWith(key)
                .build()
                .parseSignedClaims(token)
                .getPayload()
                .getSubject();
        } catch (JwtException | IllegalArgumentException e) {
            return null; // Defensive catch: polite 401 without 500 crashes
        }
    }
}

Secrets management (12-Factor App) and entity lifecycle ordering

  • 12-Factor external secrets: I externalized credentials via jwt.secret=${JWT_SECRET} in application.properties. Secrets are injected strictly through environment variables in IntelliJ and AWS, keeping git commits 100% clean.
  • The persistence sequence fix: During early testing, calling jwtTokenProvider.generateToken(newUser) before userRepository.save(newUser) threw a NullPointerException. Newly instantiated entities have id = null before database persistence. Executing save() first ensures PostgreSQL assigns the generated UUID before minting the token.
java
// Correct persistence sequence
User savedUser = userRepository.save(newUser);
String token = jwtTokenProvider.generateToken(savedUser);
UserResponseDTO responseDTO = userMapper.toUserResponseDTO(savedUser, token);

Stateless security filter

The GoogleAuthFilter extracts the Bearer token, validates the signature in memory, resolves the UserPrincipal, and sets the SecurityContextHolder:

java
@Component
public class GoogleAuthFilter extends OncePerRequestFilter {
 
    private final JwtTokenProvider jwtTokenProvider;
    private final UserRepository userRepository;
 
    @Override
    protected void doFilterInternal(
        HttpServletRequest request,
        HttpServletResponse response,
        FilterChain filterChain
    ) throws ServletException, IOException {
        String token = resolveToken(request);
        if (token != null) {
            String userIdStr = jwtTokenProvider.getUserIdFromToken(token);
            if (userIdStr != null) {
                UUID userId = UUID.fromString(userIdStr);
                userRepository.findById(userId).ifPresent(user -> {
                    UserPrincipal principal = new UserPrincipal(user);
                    UsernamePasswordAuthenticationToken auth =
                        new UsernamePasswordAuthenticationToken(
                            principal, null, principal.getAuthorities());
                    SecurityContextHolder.getContext().setAuthentication(auth);
                });
            }
        }
        filterChain.doFilter(request, response);
    }
}

Full-stack Favorites architecture: Presets vs occurrences

The problem: repetitive daily logging friction

Logging meals with AI is fast, but typing "black coffee with 30g whey protein" every single morning quickly becomes repetitive. I wanted a one-tap experience for daily staple meals.

The solution: templates vs. consumption events

The key architectural insight was cleanly decoupling Master Presets from Daily Intake Events:

text
FAVORITE_MEAL (Master Blueprint Template)  ──[1-Tap]──>  ENTRY (Concrete Daily Occurrence)
  1. FavoriteMeal: Reusable master recipe preset stored in the favorite_meal table.
  2. Entry: Concrete daily log stored in entries with source = 'FAVORITE'.
  3. 1-Tap logging: Clicking a favorite preset pill creates a brand-new Entry row in PostgreSQL. It never alters the master template.
  4. Provenance vs. Mutation: If I eat a larger portion of oatmeal today and edit the chat card, the update modifies only today's Entry row. The star icon remains lit to show provenance, but the master recipe remains pristine for tomorrow.

Jackson @JsonCreator resiliency for LLM enums

AI models frequently return uppercase strings (like "DINNER"). By default, Jackson is strictly case-sensitive and would crash with a 500 Server Error if the enum expected lowercase.

Adding a @JsonCreator factory provided complete case-insensitivity and defensive fallback:

java
public enum MealType {
    BREAKFAST, LUNCH, DINNER, SNACK;
 
    @JsonCreator
    public static MealType fromString(String value) {
        if (value == null || value.isBlank()) return null;
        try {
            return MealType.valueOf(value.trim().toUpperCase());
        } catch (IllegalArgumentException e) {
            return null; // Defensive fallback without 500 crashes
        }
    }
}

Interactive modal choice on modified presets

When a user modifies an existing favorite meal, the UI presents an intentional two-option modal:

  • "Save for Today Only": Adjusts today's intake without modifying the master recipe.
  • "Update Favorite Preset Too": Updates the master preset in PostgreSQL AND logs today's intake.

Financial protection: Tiered AI rate limiting & lazy resets

The problem: unchecked API token exhaustion

Multimodal LLMs (gpt-5.6-luna, whisper-1) have variable operational costs that scale with every prompt, image, and voice note. Without rate limits, a single runaway loop or malicious script could deplete an API budget in minutes.

The solution: lazy calendar resets & tiered quotas

AI request checks ownership and today's quota, then either calls Spring AI or returns HTTP 429

Rather than placing the entire app behind a paywall, I created a tiered daily quota system:

  • Goals with AI: 2 per day (GOAL_AI).
  • Favorites with AI: 3 per day (FAVORITE_AI).
  • Meal Entries with AI: 5 per day (ENTRY_AI across text, voice, and photos).
  • Manual Core: Manual nutrition logging (POST /api/entry/{userId}) is 100% free and unlimited ($0.00).

Lazy calendar reset vs. cron daemon pitfalls

A common instinct is setting up a midnight cron job (@Scheduled(cron = "0 0 0 * * *")) to reset counters in the database. But cron daemons introduce stateful complexity, timezone bugs, and fail if the server restarts at midnight.

Instead, I used a compound unique key in PostgreSQL:

sql
CONSTRAINT uk_user_daily_usage UNIQUE (user_id, usage_date);
CREATE INDEX idx_daily_ai_usage_lookup ON daily_ai_usage(user_id, usage_date);

When AiQuotaService looks up (userId, LocalDate.now()):

  • On a new day, PostgreSQL naturally returns Optional.empty().
  • The service lazily initializes a fresh row with counters at 0 on the user's first AI request of that day.
  • Zero cron jobs, zero background maintenance, 100% reliable resets.

IDOR prevention & HTTP 429 semantics

To prevent one user from maliciously passing another user's UUID and exhausting their daily quota, I secured the service layer with Spring Security:

java
@PreAuthorize("isAuthenticated() && #userId == principal.id")
public void saveQuota(UUID userId, AiFeatureType featureType) {
    LocalDate today = LocalDate.now();
    DailyAiUsage usage = repository.findByUserIdAndUsageDate(userId, today)
        .orElseGet(() -> new DailyAiUsage(user, today));
 
    int current = switch (featureType) {
        case GOAL_AI -> usage.getGoalsUsed();
        case FAVORITE_AI -> usage.getFavoritesUsed();
        case ENTRY_AI -> usage.getEntriesUsed();
    };
 
    int limit = switch (featureType) {
        case GOAL_AI -> 2;
        case FAVORITE_AI -> 3;
        case ENTRY_AI -> 5;
    };
 
    if (current >= limit) {
        throw new AiQuotaExceededException("Daily AI limit reached for " + featureType);
    }
 
    switch (featureType) {
        case GOAL_AI -> usage.setGoalsUsed(current + 1);
        case FAVORITE_AI -> usage.setFavoritesUsed(current + 1);
        case ENTRY_AI -> usage.setEntriesUsed(current + 1);
    }
 
    repository.save(usage);
}

When the limit is reached, GlobalExceptionHandler converts AiQuotaExceededException into HTTP 429 Too Many Requests, and the frontend shows polite feedback encouraging manual logging.

Multimodal Telegram bot & The Adapter Pattern

The problem: unifying background chat with web services

To provide frictionless mobile capture, I brought Telegram directly into the Spring Boot backend. Two architectural challenges emerged immediately:

  1. Thread security: Telegram messages arrive on a daemon worker thread ([Telegram Executor]), where SecurityContextHolder is completely empty.
  2. File interface mismatch: Telegram saves files to disk as raw java.io.File, but my Spring AI services accepted Spring's MultipartFile interface.

The solution: Long Polling, ThreadLocal auth & the Adapter Pattern

Voice, photo, and text wrap through a MultipartFile adapter so Spring AI services stay unchanged

1. The Object-Oriented Adapter Pattern (MultipartFileConvertor)

Instead of duplicating or modifying working AI services (which would violate the Open-Closed Principle), I created an Adapter implementing MultipartFile:

java
public class MultipartFileConvertor implements MultipartFile {
    private final String contentType;
    private final File file;
 
    public MultipartFileConvertor(String contentType, File file) {
        this.contentType = contentType;
        this.file = file;
    }
 
    @Override
    public String getContentType() { return contentType; }
 
    @Override
    public byte[] getBytes() throws IOException {
        return Files.readAllBytes(file.toPath());
    }
 
    @Override
    public InputStream getInputStream() throws IOException {
        return new FileInputStream(file);
    }
 
    @Override
    public long getSize() { return file.length(); }
 
    @Override
    public Resource getResource() { return new FileSystemResource(file); }
    // Remaining interface methods...
}

Because MultipartFileConvertor satisfies the MultipartFile contract, Spring AI's vision and Whisper transcription services consume Telegram media seamlessly with zero modifications.

2. ThreadLocal security context injection

When a Telegram update arrives, the bot retrieves the user by chatId, constructs a UserPrincipal, and injects it into SecurityContextHolder for the worker thread:

java
try {
    UserPrincipal principal = new UserPrincipal(user);
    UsernamePasswordAuthenticationToken auth =
        new UsernamePasswordAuthenticationToken(principal, null, principal.getAuthorities());
    SecurityContextHolder.getContext().setAuthentication(auth);
 
    // Secured services execute smoothly
    DailyProgressDTO progress = entryService.getLeftToday(user.getId());
    sendResponse(chatId, TelegramMessageFormatter.formatDailyProgress(progress));
} finally {
    SecurityContextHolder.clearContext(); // Critical: prevent thread pool identity leaks
}

3. Ephemeral media lifecycle ($0.00 storage cost & 100% privacy)

Media files are downloaded temporarily via GetFile(fileId), passed to Spring AI for analysis, written to PostgreSQL as numbers, and deleted immediately from disk (localFile.delete()). This guarantees complete user data privacy and zero ongoing AWS S3 storage bills.

4. Deep linking account pairing

Users connect their web dashboard to Telegram in one click via https://t.me/BotName?start=<UUID>. The bot parses the UUID on /start, assigns telegram_chat_id in the User record, and enables instant omnichannel sync.

Domain resilience: Custom exceptions and status codes

The problem: opaque 500 server errors

During early development, unhandled runtime errors returned generic 500 Internal Server Error responses. The frontend could not tell if a meal was missing, a field was invalid, or the server had crashed.

The solution: global exception translation

I centralized domain error translation with a @RestControllerAdvice:

  • ResourceNotFoundException: Returns a clean 404 Not Found.
  • IllegalArgumentException / MethodArgumentNotValidException: Returns 400 Bad Request.
  • AiQuotaExceededException: Returns 429 Too Many Requests.
java
@RestControllerAdvice
public class GlobalExceptionHandler {
 
    @ExceptionHandler(ResourceNotFoundException.class)
    public ResponseEntity<ErrorResponse> handleNotFound(ResourceNotFoundException ex) {
        ErrorResponse error = new ErrorResponse(
            HttpStatus.NOT_FOUND.value(),
            ex.getMessage(),
            Instant.now()
        );
        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error);
    }
 
    @ExceptionHandler(AiQuotaExceededException.class)
    public ResponseEntity<ErrorResponse> handleQuotaExceeded(AiQuotaExceededException ex) {
        ErrorResponse error = new ErrorResponse(
            HttpStatus.TOO_MANY_REQUESTS.value(),
            ex.getMessage(),
            Instant.now()
        );
        return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS).body(error);
    }
}

Stateless AI backend: Draft cards, onboarding, and Spring AI

The problem: conversational bloat vs. friction

Two product problems threatened user experience:

  1. Form fatigue: Forcing users into rigid 5-field forms defeated the zero-friction goal. But storing raw chat logs in PostgreSQL created massive database bloat for data no one ever re-read.
  2. Cold start drop-off: Asking new users to calculate BMR, TDEE, and macro splits during sign-up caused immediate abandonment.

The solution: stateless AI payloads

I resolved both challenges by keeping AI interactions completely stateless:

1. Meal Draft Cards over chat logs

When a user describes a meal ("200g grilled salmon with quinoa and avocado"):

  1. Spring AI parses the natural language and returns an AiMealResponseDTO.
  2. The UI renders this payload as an interactive Meal Draft Card.
  3. The draft is not written to PostgreSQL yet. The user inspects the macros, tweaks numbers inline, or cancels.
  4. Only when the user clicks Confirm does the frontend send an EntryRequestDTO to persist the entry.

Spring AI returns a draft card; PostgreSQL is written only after Confirm

java
// Transient DTO — never saved directly to the database
public record AiMealResponseDTO(
    String mealName,
    Integer kcal,
    Double carbs,
    Double fat,
    Double protein,
    String confidenceNote
) {}

2. Stateless AI onboarding wizard

The confirm-first wizard collects lifestyle questions into a transient record. We never clutter the database with onboarding survey data:

java
public record AiGoalRequestDTO(
    PrimaryObjective primaryObjective,
    Gender gender,
    Integer age,
    Integer heightCm,
    Integer currentWeightKg,
    Integer targetWeightKg,
    @JsonProperty("activityLevel")
    ActivityLevel dailyActivityLevel,
    @JsonProperty("dietPreference")
    DietPreference dietaryPreference
) {}

The AI calculates recommended calories and macros, returning an AiGoalResponseDTO. The user reviews the targets, makes manual adjustments if desired, and confirms. Only then is the approved goal persisted.

3. Multimodal parsing with Spring AI

Spring AI’s fluent ChatClient handles text, photo uploads, and voice notes through a unified service:

java
public AiMealResponseDTO parseMealFromText(String description) {
    if (description == null || description.isBlank()) {
        throw new IllegalArgumentException("Meal description can't be blank");
    }
    return this.chatClient.prompt()
        .system(systemPrompt)
        .user("""
            Please analyze the following meal description and estimate its nutritional breakdown:
            Meal Description: %s
            """.formatted(description))
        .call()
        .entity(AiMealResponseDTO.class);
}
 
public AiMealResponseDTO parseMealFromImage(MultipartFile image) {
    Media media = new Media(
        MimeTypeUtils.parseMimeType(image.getContentType()),
        image.getResource()
    );
    return this.chatClient.prompt()
        .system(systemPrompt)
        .user(userSpec -> userSpec
            .text("Analyze the meal depicted in this photograph...")
            .media(media))
        .call()
        .entity(AiMealResponseDTO.class);
}

To prevent hallucinations:

  • Confidence ≥ 80%: The model estimates macros using culinary heuristics; confidenceNote returns "95".
  • Confidence below 80%: The model sets macros to zero and populates confidenceNote with a clarifying question (e.g., "What dressing was on the salad?").
  • Macro validation: The system prompt enforces (protein * 4) + (carbs * 4) + (fat * 9) must match kcal within ±5%.

Orchestrating AI agents: From Figma design to React frontend

The problem: bridging design to code without a frontend team

With the Java API running and my Figma designs complete, I faced a common hurdle: how to turn a complex design system into a production-grade React + TypeScript application without getting stuck with hallucinated, out-of-sync UI code.

The solution: a specialized multi-agent pipeline

I connected AI coding agents directly to my Figma canvas using the Model Context Protocol (MCP) and orchestrated a three-agent workflow:

  1. The Figma Extractor Agent: Traversed Figma component nodes and extracted pixel-exact dimensions, padding, color tokens, and layout hierarchies directly into context.
  2. The Builder Agent: Took the extracted Figma context and generated clean React + TypeScript components styled with Tailwind CSS, strictly adhering to design tokens and connecting to the Spring Boot REST API.
  3. The QA Auditor Agent: Audited spacing and contrast against the original Figma designs, catching regressions early.

Frontend rules that became permanent in our workflow:

  • Inspect computed CSS before refactoring React: A broken .no-scrollbar utility once used display: none on the container instead of the scrollbar pseudo-element, accidentally hiding entire panels.
  • Mobile WebKit keyboard dismiss can cancel taps: Adding onMouseDown={(e) => e.preventDefault()} alongside touch-action: manipulation prevented preset button clicks from being swallowed when the soft keyboard collapsed.
  • Prefer in-place skeletons over full unmount loaders: Keeping layout stability during date switching prevents jarring visual jumps.

Testing pyramid & On-demand AWS CI/CD

The problem: MockMvc vs. real production bugs

Unit testing with @WebMvcTest and MockMvc is fast and isolated, but simulated tests cannot catch real database constraints, SQL dialect bugs, or runtime Docker container failures.

The solution: Newman suites & on-demand GitHub Actions

I established a complete testing pyramid:

  1. MockMvc Unit Tests: 128 unit tests validating controller routing, validation annotations (@Valid, @NotNull), and DTO serialization in milliseconds.
  2. Postman + Newman End-to-End Suite: Automated API suite executed against a live Tomcat server and real PostgreSQL instance, validating multi-step workflows (User Auth $\rightarrow$ Goal Creation $\rightarrow$ Meal Logging $\rightarrow$ Daily Aggregation $\rightarrow$ Deletion).
yaml
# .github/workflows/deploy-aws.yml
name: On-Demand AWS Deployment & Quality Gate
 
on:
  workflow_dispatch:
 
jobs:
  quality-gate:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:16-alpine
        env:
          POSTGRES_DB: nutrition_test
          POSTGRES_PASSWORD: test
        ports:
          - 5432:5432
    steps:
      - uses: actions/checkout@v4
      - name: Set up JDK 17
        uses: actions/setup-java@v4
        with:
          java-version: '17'
          distribution: 'temurin'
      - name: Run Unit Tests
        run: ./mvnw test
      - name: Boot Application & Run Newman Suite
        run: |
          ./mvnw spring-boot:start
          npx newman run postman/NutritionTracker_E2E.json -e postman/ci_env.json

If any test fails, deployment halts immediately with zero risk to production. When all tests pass, the pipeline builds the multi-stage Docker image, pushes to Amazon ECR, and triggers a zero-downtime rolling update on AWS.

Retrospective and lessons learned

Building a full-stack product from scratch taught me that engineering is an evolving sequence of deliberate trade-offs, continuous learning, and adapting to feedback.

1. Anticipate entity ripple effects early

One of my biggest takeaways is spending time anticipating entity relationships upfront. Introducing the FavoriteMeal and DailyAiUsage entities required updating MapStruct mappers, refactoring test fixtures, and adjusting security boundaries. Thinking through relational touchpoints early saves hours of cascading refactors.

2. Decouple IdPs from session management

Relying directly on third-party OAuth tokens for API authorization creates tight coupling, latency overhead, and token expiry headaches. Using OAuth strictly as an identity provider on initial login and issuing internal JJWTs gave the application total autonomy, sub-millisecond local authorization, and seamless CI/CD automation.

3. Embrace the Adapter Pattern for third-party streams

When integrating external platforms like Telegram, avoid polluting core domain logic with platform-specific classes (java.io.File). Writing an Adapter like MultipartFileConvertor allowed 100% code reuse of existing Spring AI services with zero modifications.

4. Java and Spring AI over multi-container sprawl

Initially, I planned to spin up a separate Python AI container to handle LLM calls. But Java and Spring AI handled multimodal vision, audio transcription, and structured DTO output natively in the same monolithic backend, proving that modern Java is exceptionally fast and capable for AI-native architectures.


Shipping this product end-to-end proved that the best way to master backend engineering and AI integration is to build something real. From a no-code bot to an enterprise Spring Boot architecture with automated CI/CD, every challenge became a permanent lesson in writing cleaner, safer, and more resilient software.

The companion product case study covers the design systems, user testing insights, and UX decisions behind Tracki.