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.
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.
// 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:
- Accidental leaks: Returning entities exposed database primary keys, internal timestamps, and triggered circular lazy-loading exceptions during JSON serialization.
- Input tampering: Accepting entities directly in
@PostMappingmeant a malicious user could supply an arbitrary databaseidand overwrite existing records. - 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
recordisprivate final. Once instantiated, payloads cannot be accidentally modified in flight. - Zero boilerplate: Automatic canonical constructors, clean accessors (
dto.kcal()),equals(), andtoString().
// 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:
@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:
@PathVariablevs@RequestBody: Path variables identify specific resources (/api/entry/{id}), while the body carries the data payload.ResponseEntity<T>status codes: Returning200 OKfor reads,201 Createdwith the new resource for creations, and204 No Contentfor successful deletions.
@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:
- 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 with401 Unauthorized. - 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.
- 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.
- Google as IdP strictly at login: Google is contacted only once when the user taps "Sign in with Google" (
POST /api/user/auth/google). - Internal App JWT: Spring Boot verifies the Google token, retrieves or creates the
Userin PostgreSQL, and generates an internal, cryptographically signed Application JWT using JJWT (io.jsonwebtoken) with a 30-day lifetime. - 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.
@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}inapplication.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)beforeuserRepository.save(newUser)threw aNullPointerException. Newly instantiated entities haveid = nullbefore database persistence. Executingsave()first ensures PostgreSQL assigns the generated UUID before minting the token.
// 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:
@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:
FAVORITE_MEAL (Master Blueprint Template) ──[1-Tap]──> ENTRY (Concrete Daily Occurrence)FavoriteMeal: Reusable master recipe preset stored in thefavorite_mealtable.Entry: Concrete daily log stored inentrieswithsource = 'FAVORITE'.- 1-Tap logging: Clicking a favorite preset pill creates a brand-new
Entryrow in PostgreSQL. It never alters the master template. - Provenance vs. Mutation: If I eat a larger portion of oatmeal today and edit the chat card, the update modifies only today's
Entryrow. 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:
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
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_AIacross 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:
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
0on 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:
@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:
- Thread security: Telegram messages arrive on a daemon worker thread (
[Telegram Executor]), whereSecurityContextHolderis completely empty. - File interface mismatch: Telegram saves files to disk as raw
java.io.File, but my Spring AI services accepted Spring'sMultipartFileinterface.
The solution: Long Polling, ThreadLocal auth & the Adapter Pattern
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:
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:
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 clean404 Not Found.IllegalArgumentException/MethodArgumentNotValidException: Returns400 Bad Request.AiQuotaExceededException: Returns429 Too Many Requests.
@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:
- 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.
- 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"):
- Spring AI parses the natural language and returns an
AiMealResponseDTO. - The UI renders this payload as an interactive Meal Draft Card.
- The draft is not written to PostgreSQL yet. The user inspects the macros, tweaks numbers inline, or cancels.
- Only when the user clicks Confirm does the frontend send an
EntryRequestDTOto persist the entry.
// 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:
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:
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;
confidenceNotereturns"95". - Confidence below 80%: The model sets macros to zero and populates
confidenceNotewith a clarifying question (e.g., "What dressing was on the salad?"). - Macro validation: The system prompt enforces
(protein * 4) + (carbs * 4) + (fat * 9)must matchkcalwithin ±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:
- The Figma Extractor Agent: Traversed Figma component nodes and extracted pixel-exact dimensions, padding, color tokens, and layout hierarchies directly into context.
- 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.
- 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-scrollbarutility once useddisplay: noneon the container instead of the scrollbar pseudo-element, accidentally hiding entire panels. - Mobile WebKit keyboard dismiss can cancel taps: Adding
onMouseDown={(e) => e.preventDefault()}alongsidetouch-action: manipulationprevented 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:
- MockMvc Unit Tests: 128 unit tests validating controller routing, validation annotations (
@Valid,@NotNull), and DTO serialization in milliseconds. - 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).
# .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.jsonIf 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.