Guided Project · Part 2 of 2
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.
| Financial App Analog (M4–M8) | GymTrack (this part) |
|---|---|
| Portfolio manager library (JAR) | gymtrack-lib Gradle subproject |
PortfolioManager + Factory | WorkoutManager + WorkoutManagerFactory |
| Publish JAR to Maven local | Publish gymtrack-lib to mavenLocal() |
| Tiingo + Alpha Vantage providers | wger + ExerciseDB providers |
StockQuoteServiceFactory | WorkoutDataProviderFactory |
Custom exceptions (StockQuoteServiceException) | WorkoutDataException hierarchy |
| Multithread stock-quote requests | Multithread exercise-detail fetches |
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.
The single app project becomes a multi-project Gradle build: a gymtrack-lib library (no main) and an app that consumes it.
rootProject.name = 'gymtrack' include 'gymtrack-lib', 'app'
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.
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.
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); }
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; } }
The Factory hides which concrete WorkoutManager and provider get wired together—this is the Factory pattern in action.
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); } }
plugins { id 'application' } dependencies { implementation project(':gymtrack-lib') // ← the library testImplementation libs.junit.jupiter } application { mainClass = 'org.gymtrack.App' }
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.
gymtrack-lib that builds a WorkoutManager via the factory with a LocalExerciseProvider and asserts that computeOverloadRates returns results sorted in descending rate order.
gymtrack-lib + appWorkoutManager public interface (the library’s only entry point)WorkoutManagerFactory hides wiring of impl + providerapp depends on the library via project(':gymtrack-lib')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 } }
./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
Now any project—including a fresh consumer—depends on GymTrack the same way it depends on Jackson: by group:artifact:version.
repositories { mavenLocal(); mavenCentral() } dependencies { implementation 'org.gymtrack:gymtrack-lib:1.0.0' }
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.
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 coordinatesgymtrack-lib-1.0.0.jar to mavenLocal()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 Analog | GymTrack | Role |
|---|---|---|
StockQuotesService | WorkoutDataProvider | Interface |
| Tiingo impl | WgerApiProvider | Primary |
| Alpha Vantage impl | ExerciseDbProvider | Backup |
StockQuoteServiceFactory | WorkoutDataProviderFactory | Selector |
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.)
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.
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; } }
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.
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(); } } }
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.
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);
args[0] ("wger", "exercisedb", or "failover") so you can switch sources without recompiling.
ExerciseDbProvider backup implementation + ExerciseDbResponse POJOWgerExerciseInfo modelWorkoutDataProviderFactory selects providers by enumFailoverProvider tries primary, falls back to backup on errorOverloadCalculator or WorkoutManagerRestClientException 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.”
Best practice: reproduce before fixing. Two distinct failures hide behind one stack trace:
| Trigger | Low-level symptom | What the user should see |
|---|---|---|
| Unknown exercise name | HttpClientErrorException: 404 | “No data for ‘X’ in any provider.” |
| No network | ResourceAccessException | “Couldn’t reach the exercise service.” |
| Malformed JSON | JsonMappingException | “Provider returned unreadable data.” |
GymTrack uses a small hierarchy so callers can distinguish “not found” from “service down.”
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); } }
The provider is the system boundary—catch framework exceptions there and re-throw typed ones. Internal code never sees a RestClientException.
@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); } }
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 } }
FailoverProvider, only fail over on ProviderUnavailableException. An ExerciseNotFoundException from wger means the backup won’t have it either—don’t waste a second call.
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.
WorkoutDataException base + ExerciseNotFound / ProviderUnavailable subtypesStart 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)
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.
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 } }
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.
try/finally { pool.shutdown() } guarantees you never leak them.
| Concern | Serial | Parallel (pool of 8) |
|---|---|---|
| 12 calls @ 760ms | ~9.1 s | ~0.9 s |
| Provider load | 1 at a time | ≤8 at a time (bounded) |
| Failure isolation | stops batch | per-future, skip & continue |
| Result order | natural | preserved via ordered futures |
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,
ExecutorServiceFutures; unwrapped typed exceptions from M7try/finally shutdown()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).
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
mavenLocal().