Guided Project · Part 2 of 2

GymTrack
Modules 4 – 8

From a working three-module app to a published, multi-provider, fault-tolerant, multi-threaded library. Package as a library, publish it, add a backup data source, harden error handling, and parallelize the API calls.

Java Gradle Interfaces & Factory Exceptions Multithreading Picks up after Module 3

Concept map Financial App → GymTrack

Financial App Analog (M4–M8)GymTrack (this part)
Portfolio manager library (JAR)gymtrack-lib Gradle subproject
PortfolioManager + FactoryWorkoutManager + WorkoutManagerFactory
Publish JAR to Maven localPublish gymtrack-lib to mavenLocal()
Tiingo + Alpha Vantage providerswger + ExerciseDB providers
StockQuoteServiceFactoryWorkoutDataProviderFactory
Custom exceptions (StockQuoteServiceException)WorkoutDataException hierarchy
Multithread stock-quote requestsMultithread exercise-detail fetches
M4

Package GymTrack as a library

Note from the Product Manager
Two other teams want to use your overload-tracking logic—the mobile team and the coaching dashboard team. Right now everything lives inside App.java. Package the core as a reusable library with a small, documented public surface so other apps can depend on it without copying code.

Split into two Gradle subprojects Objective

The single app project becomes a multi-project Gradle build: a gymtrack-lib library (no main) and an app that consumes it.

settings.gradle
rootProject.name = 'gymtrack'
include 'gymtrack-lib', 'app'
gymtrack-lib/build.gradle
plugins {
  id 'java-library'          // 'library', not 'application'
}

dependencies {
  api 'com.fasterxml.jackson.core:jackson-databind:2.17.0'
  api 'org.springframework:spring-web:6.1.6'
  testImplementation libs.junit.jupiter
  testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}

Move model/, provider/, comparator/, and service/ from Module 1–3 into gymtrack-lib/src/main/java/org/gymtrack/. The app keeps only App.java.

Define the public API Facade

Other teams should call one class, not wire up providers and calculators themselves. WorkoutManager is GymTrack’s PortfolioManager: a small facade over the provider + calculator.

gymtrack-lib/.../manager/WorkoutManager.java
package org.gymtrack.manager;

import org.gymtrack.model.OverloadResult;
import org.gymtrack.model.WorkoutEntry;
import org.gymtrack.model.WgerExerciseInfo;
import java.util.List;

/**
 * Public entry point for the GymTrack library.
 * Core interface for workout management.
 */
public interface WorkoutManager {

  /** Rank exercises by progressive-overload rate. */
  List<OverloadResult> computeOverloadRates(
      List<WorkoutEntry> log);

  /** Enrich exercises with provider muscle/equipment data. */
  List<WgerExerciseInfo> enrichExercises(
      List<WorkoutEntry> log);
}
gymtrack-lib/.../manager/WorkoutManagerImpl.java
package org.gymtrack.manager;

import org.gymtrack.model.*;
import org.gymtrack.provider.WorkoutDataProvider;
import org.gymtrack.service.OverloadCalculator;
import java.util.*;

public class WorkoutManagerImpl implements WorkoutManager {

  private final WorkoutDataProvider provider;

  public WorkoutManagerImpl(WorkoutDataProvider provider) {
    this.provider = provider;
  }

  @Override
  public List<OverloadResult> computeOverloadRates(
      List<WorkoutEntry> log) {
    return OverloadCalculator.calculate(log);
  }

  @Override
  public List<WgerExerciseInfo> enrichExercises(
      List<WorkoutEntry> log) {
    Set<String> seen = new LinkedHashSet<>();
    List<WgerExerciseInfo> out = new ArrayList<>();
    for (WorkoutEntry e : log) {
      if (seen.add(e.getExercise())) {
        WgerExerciseInfo info =
            provider.getExerciseDetails(e.getExercise());
        if (info != null) out.add(info);
      }
    }
    return out;
  }
}

Collect implementations in a Factory Design pattern

The Factory hides which concrete WorkoutManager and provider get wired together—this is the Factory pattern in action.

gymtrack-lib/.../manager/WorkoutManagerFactory.java
package org.gymtrack.manager;

import org.gymtrack.provider.WorkoutDataProvider;
import org.gymtrack.provider.WgerApiProvider;

public class WorkoutManagerFactory {

  private WorkoutManagerFactory() {}  // static-only

  /** Default manager backed by the wger API. */
  public static WorkoutManager getWorkoutManager() {
    return new WorkoutManagerImpl(new WgerApiProvider());
  }

  /** Manager backed by a caller-supplied provider. */
  public static WorkoutManager getWorkoutManager(
      WorkoutDataProvider provider) {
    return new WorkoutManagerImpl(provider);
  }
}

App now depends on the library Consume

app/build.gradle
plugins { id 'application' }

dependencies {
  implementation project(':gymtrack-lib')   // ← the library
  testImplementation libs.junit.jupiter
}

application { mainClass = 'org.gymtrack.App' }
app/.../App.java (main, simplified)
WorkoutManager manager =
    WorkoutManagerFactory.getWorkoutManager();

List<WorkoutEntry> log = readLog("workout_log.json");
List<OverloadResult> ranked =
    manager.computeOverloadRates(log);

ranked.forEach(System.out::println);

The app no longer knows about OverloadCalculator or any provider class—it talks to the library through two interfaces only.

Try it
Write a JUnit test in gymtrack-lib that builds a WorkoutManager via the factory with a LocalExerciseProvider and asserts that computeOverloadRates returns results sorted in descending rate order.
  • Multi-project Gradle build: gymtrack-lib + app
  • WorkoutManager public interface (the library’s only entry point)
  • WorkoutManagerFactory hides wiring of impl + provider
  • app depends on the library via project(':gymtrack-lib')
M5

Publish the library

Add the maven-publish plugin Gradle

gymtrack-lib/build.gradle (additions)
plugins {
  id 'java-library'
  id 'maven-publish'          // ← add this
}

group = 'org.gymtrack'
version = '1.0.0'             // semantic version

publishing {
  publications {
    maven(MavenPublication) {
      from components.java       // publish the JAR
      artifactId = 'gymtrack-lib'
    }
  }
  repositories {
    mavenLocal()              // ~/.m2/repository
  }
}

Build & publish Commands

./gradlew :gymtrack-lib:build
./gradlew :gymtrack-lib:publishToMavenLocal

This drops the artifact at:

~/.m2/repository/org/gymtrack/gymtrack-lib/1.0.0/
  ├─ gymtrack-lib-1.0.0.jar
  ├─ gymtrack-lib-1.0.0.pom
  └─ ... checksums

Consume by coordinates Versioning

Now any project—including a fresh consumer—depends on GymTrack the same way it depends on Jackson: by group:artifact:version.

some-consumer/build.gradle
repositories { mavenLocal(); mavenCentral() }

dependencies {
  implementation 'org.gymtrack:gymtrack-lib:1.0.0'
}
Why versioning matters: bump to 1.1.0 when you add the Module 6 second provider, 1.1.1 for the Module 7 exception fixes. Consumers pin a version and upgrade deliberately.
Try it
Generate a sources JAR and a Javadoc JAR by adding withSourcesJar() and withJavadocJar() inside a java { } block, then republish. Confirm three JARs land in ~/.m2. This is what makes a library pleasant for the teams consuming it.
  • maven-publish plugin + group / version coordinates
  • Published gymtrack-lib-1.0.0.jar to mavenLocal()
  • Consumed the library by coordinates, not by source path
M6

Add another data provider

Note from the Product Manager
The wger API rate-limits us during peak hours, and when it throttles, coaches lose exercise detail and fall back to manual lookups. We’ve lined up a backup source. Integrate it as a second provider so the app keeps working when wger is unavailable—and make switching providers a one-line change.

The pattern already fits Why it's easy now

Module 3 already extracted WorkoutDataProvider. Adding a provider means one new implementation—no calculator or manager changes. This is the payoff of programming to an interface.

Financial AnalogGymTrackRole
StockQuotesServiceWorkoutDataProviderInterface
Tiingo implWgerApiProviderPrimary
Alpha Vantage implExerciseDbProviderBackup
StockQuoteServiceFactoryWorkoutDataProviderFactorySelector

New implementation Backup source

A second provider hitting a different free exercise API (ExerciseDB-style). It maps that API’s response shape into the same WgerExerciseInfo model the rest of the code already expects. (This is also where setName earns its keep: a backup provider sets the name directly.)

gymtrack-lib/.../provider/ExerciseDbProvider.java
package org.gymtrack.provider;

import org.gymtrack.model.*;
import org.springframework.web.client.RestTemplate;
import java.util.*;

public class ExerciseDbProvider implements WorkoutDataProvider {

  private final RestTemplate rest = new RestTemplate();
  private static final String BASE =
      "https://exercisedb-api.example/api/v1";

  @Override
  public WgerExerciseInfo getExerciseDetails(String name) {
    String url = BASE + "/exercises/name/"
        + name.toLowerCase().replace(" ", "%20");

    ExerciseDbResponse[] hits =
        rest.getForObject(url, ExerciseDbResponse[].class);
    if (hits == null || hits.length == 0) return null;

    // Adapt the foreign shape into our model
    return toWgerShape(hits[0]);
  }

  private WgerExerciseInfo toWgerShape(ExerciseDbResponse r) {
    WgerExerciseInfo info = new WgerExerciseInfo();
    info.setName(r.getName());
    WgerCategory cat = new WgerCategory();
    cat.setName(r.getBodyPart());
    info.setCategory(cat);

    List<WgerMuscle> muscles = new ArrayList<>();
    for (String m : r.getTargetMuscles()) {
      WgerMuscle wm = new WgerMuscle();
      wm.setNameEn(m);
      muscles.add(wm);
    }
    info.setMuscles(muscles);
    info.setMusclesSecondary(List.of());
    info.setEquipment(List.of());
    return info;
  }

  @Override
  public String getProviderName() { return "ExerciseDB (backup)"; }
}

Add a tiny POJO for that API’s response—same Jackson pattern as the wger POJOs from Module 2.

gymtrack-lib/.../model/ExerciseDbResponse.java
package org.gymtrack.model;

import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.List;

@JsonIgnoreProperties(ignoreUnknown = true)
public class ExerciseDbResponse {
  private String name;
  @JsonProperty("bodyPart") private String bodyPart;
  @JsonProperty("targetMuscles") private List<String> targetMuscles;

  public ExerciseDbResponse() {}
  public String getName() { return name; }
  public void setName(String n) { name = n; }
  public String getBodyPart() { return bodyPart; }
  public void setBodyPart(String b) { bodyPart = b; }
  public List<String> getTargetMuscles() { return targetMuscles; }
  public void setTargetMuscles(List<String> t) { targetMuscles = t; }
}

The provider factory Selector

Push the wger logic down to a sibling and let a factory choose. The existing provider drops to a lower layer, a new provider joins at the same level, and a factory selects between them.

gymtrack-lib/.../provider/WorkoutDataProviderFactory.java
package org.gymtrack.provider;

public class WorkoutDataProviderFactory {

  public enum Source { WGER, EXERCISE_DB }

  public static WorkoutDataProvider get(Source source) {
    switch (source) {
      case EXERCISE_DB: return new ExerciseDbProvider();
      case WGER:
      default:        return new WgerApiProvider();
    }
  }
}

Failover wiring Reliability

The point of a backup is to use it when the primary fails. A thin composite provider tries wger first, falls back to ExerciseDB on error.

gymtrack-lib/.../provider/FailoverProvider.java
public class FailoverProvider implements WorkoutDataProvider {

  private final WorkoutDataProvider primary, backup;

  public FailoverProvider(WorkoutDataProvider p, WorkoutDataProvider b) {
    primary = p; backup = b;
  }

  @Override
  public WgerExerciseInfo getExerciseDetails(String name) {
    try {
      WgerExerciseInfo info = primary.getExerciseDetails(name);
      if (info != null) return info;
    } catch (RuntimeException e) {
      System.out.println("  primary failed, using backup: " + e);
    }
    return backup.getExerciseDetails(name);   // failover
  }

  @Override
  public String getProviderName() {
    return primary.getProviderName() + " → " + backup.getProviderName();
  }
}

Switching the whole app to use both providers is now one line:

WorkoutDataProvider provider = new FailoverProvider(
    WorkoutDataProviderFactory.get(Source.WGER),
    WorkoutDataProviderFactory.get(Source.EXERCISE_DB));

WorkoutManager manager =
    WorkoutManagerFactory.getWorkoutManager(provider);
Try it
Drive the provider from args[0] ("wger", "exercisedb", or "failover") so you can switch sources without recompiling.
  • ExerciseDbProvider backup implementation + ExerciseDbResponse POJO
  • Adapted a foreign API shape into the existing WgerExerciseInfo model
  • WorkoutDataProviderFactory selects providers by enum
  • FailoverProvider tries primary, falls back to backup on error
  • Zero changes to OverloadCalculator or WorkoutManager
M7

Handle user issues

Bug report from support
“A coach typed an exercise name that doesn’t exist in either provider and the app crashed with a wall of RestClientException stack trace. Another user with no internet saw the same crash. Make failures legible—tell the user what went wrong, not a Spring stack dump.”

Step 1 — reproduce & observe Debug

Best practice: reproduce before fixing. Two distinct failures hide behind one stack trace:

TriggerLow-level symptomWhat the user should see
Unknown exercise nameHttpClientErrorException: 404“No data for ‘X’ in any provider.”
No networkResourceAccessException“Couldn’t reach the exercise service.”
Malformed JSONJsonMappingException“Provider returned unreadable data.”

Step 2 — a typed exception hierarchy Custom exceptions

GymTrack uses a small hierarchy so callers can distinguish “not found” from “service down.”

gymtrack-lib/.../exception/WorkoutDataException.java
package org.gymtrack.exception;

/** Base for any failure fetching workout data. */
public class WorkoutDataException extends RuntimeException {
  public WorkoutDataException(String msg, Throwable cause) {
    super(msg, cause);
  }
  public WorkoutDataException(String msg) { super(msg); }
}
/** The exercise isn't available from this provider. */
public class ExerciseNotFoundException extends WorkoutDataException {
  public ExerciseNotFoundException(String name) {
    super("No data found for exercise: " + name);
  }
}

/** The provider could not be reached / failed. */
public class ProviderUnavailableException extends WorkoutDataException {
  public ProviderUnavailableException(String provider, Throwable cause) {
    super("Provider unavailable: " + provider, cause);
  }
}

Step 3 — translate at the boundary Wrap

The provider is the system boundary—catch framework exceptions there and re-throw typed ones. Internal code never sees a RestClientException.

gymtrack-lib/.../provider/WgerApiProvider.java (hardened)
@Override
public WgerExerciseInfo getExerciseDetails(String name) {
  Integer id = EXERCISE_IDS.get(name);
  if (id == null) {
    throw new ExerciseNotFoundException(name);
  }
  String url = BASE_URL + "/exerciseinfo/" + id + "/?format=json";
  try {
    return rest.getForObject(url, WgerExerciseInfo.class);
  } catch (HttpClientErrorException.NotFound e) {
    throw new ExerciseNotFoundException(name);
  } catch (ResourceAccessException e) {
    throw new ProviderUnavailableException(getProviderName(), e);
  } catch (RestClientException e) {
    throw new WorkoutDataException(
        "Unexpected error from " + getProviderName(), e);
  }
}

Step 4 — act on the type, not the message Caller

Now the failover and the app can branch on what failed.

for (String name : exerciseNames) {
  try {
    WgerExerciseInfo info = provider.getExerciseDetails(name);
    render(info);
  } catch (ExerciseNotFoundException e) {
    System.out.println("  — skipped: " + e.getMessage());
  } catch (ProviderUnavailableException e) {
    System.err.println("  service down, aborting batch: "
        + e.getMessage());
    break;          // no point continuing if provider is down
  }
}
Failover gets smarter too: in FailoverProvider, only fail over on ProviderUnavailableException. An ExerciseNotFoundException from wger means the backup won’t have it either—don’t waste a second call.
Try it
Write JUnit tests with assertThrows(ExerciseNotFoundException.class, ...) for an unknown exercise, and a mocked RestTemplate that throws ResourceAccessException to assert ProviderUnavailableException is surfaced. The key discipline is reproducing then handling.
  • Reproduced and categorized three distinct failure modes
  • WorkoutDataException base + ExerciseNotFound / ProviderUnavailable subtypes
  • Translated framework exceptions to typed ones at the provider boundary
  • Callers branch on exception type; failover skips pointless retries
M8

Enhance performance

Note from the Product Manager
A coach with 30 exercises waits ~9 seconds for the dashboard because we fetch each exercise’s details one after another. The calls are independent—fire them in parallel and the wait should collapse to roughly the slowest single call.

Step 1 — measure the baseline Serial

Start with a before-number. Time the existing sequential enrichment.

long start = System.currentTimeMillis();
List<WgerExerciseInfo> serial = manager.enrichExercises(log);
long serialMs = System.currentTimeMillis() - start;
System.out.println("Serial: " + serialMs + " ms");
// e.g. Serial: 9180 ms  (12 exercises x ~760ms each)

Step 2 — parallelize with a thread pool ExecutorService

Each fetch is independent and I/O-bound—the ideal case for concurrency. Submit one Callable per exercise to an ExecutorService and collect the Futures.

gymtrack-lib/.../manager/WorkoutManagerImpl.java (parallel)
import java.util.concurrent.*;

public List<WgerExerciseInfo> enrichExercisesParallel(
    List<WorkoutEntry> log) {

  List<String> names = log.stream()
      .map(WorkoutEntry::getExercise)
      .distinct()
      .collect(Collectors.toList());

  ExecutorService pool = Executors.newFixedThreadPool(8);
  try {
    // 1. Submit all fetches — they run concurrently
    List<Future<WgerExerciseInfo>> futures = new ArrayList<>();
    for (String name : names) {
      futures.add(pool.submit(() ->
          provider.getExerciseDetails(name)));
    }

    // 2. Collect results in order
    List<WgerExerciseInfo> out = new ArrayList<>();
    for (Future<WgerExerciseInfo> f : futures) {
      try {
        WgerExerciseInfo info = f.get();   // blocks for this one
        if (info != null) out.add(info);
      } catch (ExecutionException e) {
        // unwrap the typed exception from Module 7
        if (e.getCause() instanceof ExerciseNotFoundException) {
          continue;                       // skip, keep going
        }
        throw new WorkoutDataException("Parallel fetch failed", e);
      } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
        break;
      }
    }
    return out;
  } finally {
    pool.shutdown();                       // always release threads
  }
}

Step 3 — measure & compare Result

long start = System.currentTimeMillis();
List<WgerExerciseInfo> fast = manager.enrichExercisesParallel(log);
long parallelMs = System.currentTimeMillis() - start;

System.out.printf("Serial:   %d ms%n", serialMs);
System.out.printf("Parallel: %d ms  (%.1fx faster)%n",
    parallelMs, serialMs / (double) parallelMs);
Serial:   9180 ms
Parallel: 940 ms  (9.8x faster)

With 8 worker threads and 12 independent ~760 ms calls, wall-clock time collapses toward the slowest single request instead of their sum.

Why a fixed pool, not a thread per call? Spawning 1,000 threads for 1,000 exercises exhausts memory and hammers the API. A bounded pool (8–16) caps concurrency, respects the provider’s rate limit, and reuses threads. try/finally { pool.shutdown() } guarantees you never leak them.
ConcernSerialParallel (pool of 8)
12 calls @ 760ms~9.1 s~0.9 s
Provider load1 at a time≤8 at a time (bounded)
Failure isolationstops batchper-future, skip & continue
Result ordernaturalpreserved via ordered futures
Try it
Swap the manual ExecutorService for CompletableFuture.supplyAsync(...) + allOf(...).join() and compare readability. Then write a JUnit test asserting the parallel result set equals the serial result set—correctness must survive the optimization,
  • Measured the serial baseline before optimizing
  • Parallelized independent fetches with a bounded ExecutorService
  • Preserved result order via ordered Futures; unwrapped typed exceptions from M7
  • Guaranteed thread cleanup with try/finally shutdown()
  • Verified ~10x speedup and equal results vs. serial
DONE

What you built (M4–M8)

GymTrack went from a three-module script to a real product: a versioned, published library (M4–M5) with a clean facade; a multi-provider data layer with failover (M6); legible, typed error handling at the boundary (M7); and a multithreaded fetch path that’s ~10x faster while staying correct (M8).

Full file tree after Module 8

gymtrack/
├─ settings.gradle            (M4: include lib + app)
├─ gymtrack-lib/
│  ├─ build.gradle           (M4 java-library, M5 maven-publish)
│  └─ src/main/java/org/gymtrack/
│     ├─ manager/
│     │  ├─ WorkoutManager.java            (M4)
│     │  ├─ WorkoutManagerImpl.java        (M4, M8 parallel)
│     │  └─ WorkoutManagerFactory.java     (M4)
│     ├─ provider/
│     │  ├─ WorkoutDataProvider.java       (M3)
│     │  ├─ WgerApiProvider.java           (M3, M7 hardened)
│     │  ├─ ExerciseDbProvider.java        (M6)
│     │  ├─ WorkoutDataProviderFactory.java(M6)
│     │  └─ FailoverProvider.java          (M6, M7-aware)
│     ├─ exception/
│     │  ├─ WorkoutDataException.java       (M7)
│     │  ├─ ExerciseNotFoundException.java  (M7)
│     │  └─ ProviderUnavailableException.java (M7)
│     ├─ model/  (WgerExerciseInfo, WgerTranslation, OverloadResult, ExerciseDbResponse...)
│     ├─ comparator/  (M2)
│     └─ service/     (OverloadCalculator, M3)
└─ app/
   ├─ build.gradle           (M4 depends on :gymtrack-lib)
   └─ src/main/java/org/gymtrack/App.java
Next, if you want to keep going: wire the published library into a Spring Boot REST endpoint + simple frontend , add a caching layer in front of the providers, or push the JAR to a real remote Maven repo instead of mavenLocal().