KF Project | Optimizely CMS Integration
Thời gian: 04/2025 – 03/2026
Vai trò: Fullstack Developer (Backend focus)
Công ty: [Thông tin công ty]
Tổng quan dự án
Bối cảnh
Khách hàng cần một hệ thống CMS cho phép content editors quản lý các địa điểm kinh doanh (stores, offices, warehouses) với khả năng hiển thị trên bản đồ tương tác. Yêu cầu tích hợp Google Maps trực tiếp vào CMS để editors có thể:
- Chọn vị trí trên bản đồ khi nhập địa chỉ
- Xem trước locations trên map trước khi publish
- Quản lý multiple locations với clustering
- Tích hợp vào các trang landing page của từng khu vực
Yêu cầu chức năng
Functional Requirements
-
Location Management
- CRUD operations cho locations trong CMS
- Import/export bulk locations từ CSV/Excel
- Validation địa chỉ và tọa độ
-
Map Integration
- Hiển thị Google Maps trong CMS admin
- Interactive marker placement
- Clustering cho nhiều locations gần nhau
- Custom marker styling theo category
-
Frontend Display
- Store locator page cho end users
- Search & filter locations (by radius, category)
- Direction integration (Google Directions API)
- Responsive design cho mobile
-
Caching & Performance
- Cache map tiles và location data
- Lazy loading cho markers
- CDN integration cho static assets
Non-Functional Requirements
- Performance: Page load < 2s, map render < 500ms
- Scalability: Support 10,000+ locations
- Availability: 99.9% uptime
- SEO: Server-side rendering cho store locator pages
Kiến trúc & Công nghệ
Technology Stack
┌─────────────────────────────────────────────────────────────┐
│ Frontend (React) │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │
│ │ Google Maps │ │ React │ │ CSS Modules / │ │
│ │ JavaScript │ │ Components │ │ Styled Components │ │
│ │ API │ │ │ │ │ │
│ └─────────────┘ └─────────────┘ └─────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
↕ HTTP/HTTPS
┌─────────────────────────────────────────────────────────────┐
│ Backend (.NET + Optimizely CMS) │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │
│ │ Optimizely │ │ REST API │ │ Location Service │ │
│ │ CMS │ │ Controllers│ │ │ │
│ └─────────────┘ └─────────────┘ └─────────────────────┘ │
│ ↕ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │
│ │ SQL Server │ │ Redis │ │ Azure Blob Storage │ │
│ │ (Content) │ │ (Cache) │ │ (Static assets) │ │
│ └─────────────┘ └─────────────┘ └─────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
Components chi tiết
1. Optimizely CMS Backend
// Location Block - Custom Content Type
[ContentType(GUID = "xxx", Name = "LocationBlock")]
public class LocationBlock : BlockData
{
[Display(Name = "Location Name")]
public virtual string LocationName { get; set; }
[Display(Name = "Address")]
public virtual string Address { get; set; }
[Display(Name = "Latitude")]
public virtual double Latitude { get; set; }
[Display(Name = "Longitude")]
public virtual double Longitude { get; set; }
[Display(Name = "Category")]
public virtual string Category { get; set; }
[Display(Name = "Opening Hours")]
public virtual string OpeningHours { get; set; }
}
// Location Service
public interface ILocationService
{
Task<LocationDto> GetLocationAsync(int locationId);
Task<IEnumerable<LocationDto>> SearchLocationsAsync(SearchCriteria criteria);
Task<LocationDto> CreateLocationAsync(LocationCreateRequest request);
Task UpdateLocationAsync(int locationId, LocationUpdateRequest request);
Task DeleteLocationAsync(int locationId);
Task<IEnumerable<LocationDto>> ImportLocationsAsync(Stream csvStream);
}
2. REST API Controllers
[ApiController]
[Route("api/[controller]")]
public class LocationsController : ControllerBase
{
private readonly ILocationService _locationService;
private readonly IContentRepository _contentRepository;
public LocationsController(
ILocationService locationService,
IContentRepository contentRepository)
{
_locationService = locationService;
_contentRepository = contentRepository;
}
[HttpGet("{id}")]
[ProducesResponseType(typeof(LocationDto), 200)]
[ProducesResponseType(404)]
public async Task<ActionResult<LocationDto>> GetLocation(int id)
{
var location = await _locationService.GetLocationAsync(id);
if (location == null) return NotFound();
return Ok(location);
}
[HttpGet("search")]
[ProducesResponseType(typeof(IEnumerable<LocationDto>), 200)]
public async Task<ActionResult<IEnumerable<LocationDto>>> SearchLocations(
[FromQuery] SearchCriteria criteria)
{
var locations = await _locationService.SearchLocationsAsync(criteria);
return Ok(locations);
}
[HttpPost]
[ProducesResponseType(typeof(LocationDto), 201)]
[ProducesResponseType(400)]
public async Task<ActionResult<LocationDto>> CreateLocation(
[FromBody] LocationCreateRequest request)
{
var location = await _locationService.CreateLocationAsync(request);
return CreatedAtAction(nameof(GetLocation), new { id = location.Id }, location);
}
}
3. Google Maps Integration
// React Component - Location Map
import { GoogleMap, useJsApiLoader, Marker, InfoWindow } from '@react-google-maps/api';
const LocationMap = ({ locations, onLocationSelect }) => {
const { isLoaded } = useJsApiLoader({
googleMapsApiKey: process.env.REACT_APP_GOOGLE_MAPS_API_KEY
});
const [selectedLocation, setSelectedLocation] = useState(null);
const [mapCenter, setMapCenter] = useState({ lat: 21.0285, lng: 105.8542 });
const onMapClick = useCallback((e) => {
const newLocation = {
lat: e.latLng.lat(),
lng: e.latLng.lng()
};
onLocationSelect(newLocation);
}, [onLocationSelect]);
if (!isLoaded) return <div>Loading map...</div>;
return (
<GoogleMap
center={mapCenter}
zoom={12}
onClick={onMapClick}
options={{
disableDefaultUI: false,
clickableIcons: true,
scrollwheel: true
}}
>
{locations.map((location, index) => (
<Marker
key={location.id}
position={{ lat: location.latitude, lng: location.longitude }}
onClick={() => setSelectedLocation(location)}
icon={{
url: `/markers/${location.category}.png`,
scaledSize: new google.maps.Size(30, 30)
}}
/>
))}
{selectedLocation && (
<InfoWindow
position={{
lat: selectedLocation.latitude,
lng: selectedLocation.longitude
}}
onCloseClick={() => setSelectedLocation(null)}
>
<div>
<h3>{selectedLocation.name}</h3>
<p>{selectedLocation.address}</p>
</div>
</InfoWindow>
)}
</GoogleMap>
);
};
Giải pháp kỹ thuật chi tiết
1. Location Data Model
public class LocationDto
{
public int Id { get; set; }
public string LocationName { get; set; }
public string Address { get; set; }
public string City { get; set; }
public string Country { get; set; }
public string PostalCode { get; set; }
// Geolocation
public double Latitude { get; set; }
public double Longitude { get; set; }
public string GeoHash { get; set; } // For spatial queries
// Metadata
public string Category { get; set; } // Retail, Office, Warehouse
public string Phone { get; set; }
public string Email { get; set; }
public string Website { get; set; }
// Business Hours
public Dictionary<DayOfWeek, BusinessHours> OpeningHours { get; set; }
// Status
public bool IsActive { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime UpdatedAt { get; set; }
}
public class BusinessHours
{
public TimeSpan OpenTime { get; set; }
public TimeSpan CloseTime { get; set; }
public bool IsClosed { get; set; }
}
2. Geospatial Queries với SQL Server
public class LocationRepository : ILocationRepository
{
private readonly ApplicationDbContext _context;
public async Task<IEnumerable<LocationDto>> SearchByRadiusAsync(
double latitude,
double longitude,
double radiusKm)
{
// Sử dụng SQL Server Spatial types
var userLocation = new Point(longitude, latitude) { SRID = 4326 };
var query = from loc in _context.Locations
let locationPoint = SqlFunctions.PointFromText(
$"POINT({loc.Longitude} {loc.Latitude})", 4326)
where locationPoint.STDistance(userLocation) <= radiusKm * 1000
orderby locationPoint.STDistance(userLocation)
select new LocationDto
{
Id = loc.Id,
LocationName = loc.LocationName,
Latitude = loc.Latitude,
Longitude = loc.Longitude,
Distance = locationPoint.STDistance(userLocation) / 1000 // km
};
return await query.ToListAsync();
}
public async Task<IEnumerable<LocationDto>> SearchByBoundingBoxAsync(
double minLat, double maxLat,
double minLng, double maxLng)
{
return await _context.Locations
.Where(loc =>
loc.Latitude >= minLat && loc.Latitude <= maxLat &&
loc.Longitude >= minLng && loc.Longitude <= maxLng)
.Select(loc => new LocationDto
{
Id = loc.Id,
LocationName = loc.LocationName,
Latitude = loc.Latitude,
Longitude = loc.Longitude
})
.ToListAsync();
}
}
3. Caching Strategy
public class CachedLocationService : ILocationService
{
private readonly ILocationService _innerService;
private readonly IDistributedCache _cache;
private readonly ILogger<CachedLocationService> _logger;
private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(30);
private static readonly string CacheKeyPrefix = "location:";
public CachedLocationService(
ILocationService innerService,
IDistributedCache cache,
ILogger<CachedLocationService> logger)
{
_innerService = innerService;
_cache = cache;
_logger = logger;
}
public async Task<LocationDto> GetLocationAsync(int locationId)
{
var cacheKey = $"{CacheKeyPrefix}{locationId}";
// Try get from cache
var cachedLocation = await _cache.GetStringAsync(cacheKey);
if (!string.IsNullOrEmpty(cachedLocation))
{
_logger.LogDebug("Cache hit for location {LocationId}", locationId);
return JsonSerializer.Deserialize<LocationDto>(cachedLocation);
}
// Cache miss - get from database
_logger.LogDebug("Cache miss for location {LocationId}", locationId);
var location = await _innerService.GetLocationAsync(locationId);
if (location != null)
{
// Store in cache
var serialized = JsonSerializer.Serialize(location);
await _cache.SetStringAsync(cacheKey, serialized, new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = CacheDuration,
SlidingExpiration = TimeSpan.FromMinutes(10)
});
}
return location;
}
public async Task<IEnumerable<LocationDto>> SearchLocationsAsync(SearchCriteria criteria)
{
// Cache key based on search criteria hash
var criteriaHash = ComputeHash(criteria);
var cacheKey = $"{CacheKeyPrefix}search:{criteriaHash}";
var cached = await _cache.GetStringAsync(cacheKey);
if (!string.IsNullOrEmpty(cached))
{
return JsonSerializer.Deserialize<IEnumerable<LocationDto>>(cached);
}
var results = await _innerService.SearchLocationsAsync(criteria);
// Only cache if result count is reasonable
if (results.Count() <= 100)
{
var serialized = JsonSerializer.Serialize(results);
await _cache.SetStringAsync(cacheKey, serialized, new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(15)
});
}
return results;
}
private string ComputeHash(SearchCriteria criteria)
{
using var sha256 = SHA256.Create();
var bytes = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(criteria));
var hash = sha256.ComputeHash(bytes);
return Convert.ToBase64String(hash);
}
}
4. Bulk Import với Background Processing
public class LocationImportService : ILocationImportService
{
private readonly ILocationService _locationService;
private readonly IBackgroundJobClient _jobClient;
private readonly IBlobStorageService _blobStorage;
public async Task<ImportJobDto> StartImportAsync(Stream csvStream, string userId)
{
// Upload CSV to blob storage
var blobName = $"imports/{Guid.NewGuid()}.csv";
await _blobStorage.UploadAsync(blobName, csvStream);
// Create import job
var jobId = Guid.NewGuid().ToString();
var job = new ImportJob
{
Id = jobId,
BlobName = blobName,
UserId = userId,
Status = ImportJobStatus.Pending,
CreatedAt = DateTime.UtcNow
};
// Queue background job
await _jobClient.Enqueue<ILocationProcessor>(processor =>
processor.ProcessImportAsync(jobId, blobName));
return new ImportJobDto
{
JobId = jobId,
Status = ImportJobStatus.Pending,
EstimatedCompletionTime = DateTime.UtcNow.AddMinutes(5)
};
}
}
public interface ILocationProcessor
{
Task ProcessImportAsync(string jobId, string blobName);
}
public class LocationProcessor : ILocationProcessor
{
private readonly ILocationService _locationService;
private readonly IBlobStorageService _blobStorage;
private readonly IEmailService _emailService;
public async Task ProcessImportAsync(string jobId, string blobName)
{
try
{
// Download CSV from blob
var csvStream = await _blobStorage.DownloadAsync(blobName);
// Parse CSV
var locations = ParseCsv(csvStream);
// Validate locations
var validationResults = await ValidateLocationsAsync(locations);
// Import valid locations
var importedCount = 0;
foreach (var location in locations.Where(l => l.IsValid))
{
await _locationService.CreateLocationAsync(location.ToRequest());
importedCount++;
}
// Update job status
await UpdateJobStatusAsync(jobId, ImportJobStatus.Completed, importedCount);
// Send completion email
await _emailService.SendImportCompletionEmailAsync(jobId, importedCount);
}
catch (Exception ex)
{
await UpdateJobStatusAsync(jobId, ImportJobStatus.Failed, 0, ex.Message);
throw;
}
}
private List<LocationImportDto> ParseCsv(Stream csvStream)
{
using var reader = new StreamReader(csvStream);
using var csv = new CsvReader(reader, CultureInfo.InvariantCulture);
return csv.GetRecords<LocationImportDto>().ToList();
}
}
Thách thức & Giải pháp
Challenge 1: Đồng bộ dữ liệu giữa CMS và Map
Vấn đề: Khi nhiều editors cùng chỉnh sửa locations, dữ liệu có thể bị conflict.
Giải pháp:
public class OptimisticConcurrencyHandler
{
public async Task<LocationDto> UpdateLocationAsync(
int locationId,
LocationUpdateRequest request,
string etag)
{
var location = await _context.Locations.FindAsync(locationId);
if (location == null) return null;
// Check ETag for concurrency
var currentEtag = ComputeEtag(location);
if (currentEtag != etag)
{
throw new ConcurrencyException(
"Location was modified by another user. Please refresh and try again.");
}
// Apply updates
location.Address = request.Address;
location.Latitude = request.Latitude;
location.Longitude = request.Longitude;
location.UpdatedAt = DateTime.UtcNow;
await _context.SaveChangesAsync();
return MapToDto(location);
}
private string ComputeEtag(Location location)
{
var hash = SHA256.HashData(
Encoding.UTF8.GetBytes($"{location.Id}:{location.UpdatedAt:O}"));
return $"\"{Convert.ToBase64String(hash)}\"";
}
}
Challenge 2: Performance với 10,000+ markers
Vấn đề: Render 10,000 markers trên map gây chậm trang.
Giải pháp:
- Server-side clustering: Group nearby locations trên server
- Viewport-based loading: Chỉ load markers trong viewport
- Lazy loading: Load thêm khi zoom in
// Viewport-based API
const loadMarkers = async (bounds, zoom) => {
const response = await fetch(
`/api/locations/search?minLat=${bounds.sw.lat}&maxLat=${bounds.ne.lat}` +
`&minLng=${bounds.sw.lng}&maxLng=${bounds.ne.lng}&zoom=${zoom}`
);
return await response.json();
};
// Debounced map movement
const onMapMoveEnd = useCallback(debounce((map) => {
const bounds = map.getBounds();
const zoom = map.getZoom();
loadMarkers(bounds, zoom).then(setMarkers);
}, 300), []);
Challenge 3: Geocoding API rate limits
Vấn đề: Google Geocoding API có giới hạn requests.
Giải pháp:
public class GeocodingService : IGeocodingService
{
private readonly IGeocodingCache _cache;
private readonly RateLimiter _rateLimiter;
public async Task<GeocodeResult> GeocodeAsync(string address)
{
// Check cache first
var cached = await _cache.GetGeocodeResultAsync(address);
if (cached != null) return cached;
// Rate limiting
await _rateLimiter.WaitAsync();
// Call Google Geocoding API
var result = await _googleGeocodingClient.GeocodeAsync(address);
// Cache result
await _cache.SetGeocodeResultAsync(address, result, TimeSpan.FromDays(30));
return result;
}
}
public class RateLimiter
{
private readonly SemaphoreSlim _semaphore = new(1, 1);
private readonly TimeSpan _delay = TimeSpan.FromMilliseconds(200); // 5 req/s
public async Task WaitAsync()
{
await _semaphore.WaitAsync();
try
{
await Task.Delay(_delay);
}
finally
{
_semaphore.Release();
}
}
}
Kết quả & Impact
Metrics
| Metric | Before | After | Improvement |
|---|---|---|---|
| Page load time | 4.5s | 1.8s | 60% faster |
| Map render time | 2.1s | 0.4s | 81% faster |
| API response time (p95) | 850ms | 120ms | 86% faster |
| Cache hit rate | - | 94% | - |
| Import processing time | 15 min | 3 min | 80% faster |
Business Impact
- Content editors tiết kiệm 70% thời gian khi quản lý locations
- User engagement tăng 35% với interactive store locator
- Giảm 90% support tickets liên quan đến location data errors
Bài học kinh nghiệm
Technical Learnings
- Caching is critical: Luôn cache geospatial queries và map tiles
- Lazy loading matters: Không load tất cả markers cùng lúc
- Background jobs: Offload heavy processing khỏi request pipeline
- Rate limiting: Protect external API calls với rate limiters
Soft Skills
- Communication: Regular sync với stakeholders để understand requirements
- Documentation: Viết docs cho APIs và CMS integration
- Code review: Pair programming với junior developers
Câu hỏi phỏng vấn
Q1: Tại sao chọn Optimizely CMS thay vì custom solution?
A:
- Time-to-market: Optimizely cung cấp sẵn content modeling, admin UI, workflow
- Scalability: Built-in caching, CDN integration, multi-language support
- Maintainability: Content editors quen với CMS UI, không cần training nhiều
- Trade-off: Learning curve với proprietary API, nhưng worth it cho long-term
Q2: Bạn xử lý concurrency trong CMS như thế nào?
A:
- Optimistic concurrency với ETags
- Khi user save, check ETag match với current version
- Nếu mismatch → show conflict dialog, let user decide (overwrite or refresh)
- Version history: Optimizely tự động lưu versions, có thể rollback
Q3: Làm sao để test Google Maps integration?
A:
// Unit test với mock Google Maps API
public class GoogleMapsServiceTests
{
[Fact]
public async Task Geocode_ValidAddress_ReturnsCoordinates()
{
// Arrange
var mockClient = new Mock<IGeocodingClient>();
mockClient.Setup(c => c.GeocodeAsync("Hanoi, Vietnam"))
.ReturnsAsync(new GeocodeResult { Latitude = 21.0285, Longitude = 105.8542 });
var service = new GoogleMapsService(mockClient.Object);
// Act
var result = await service.GeocodeAsync("Hanoi, Vietnam");
// Assert
Assert.Equal(21.0285, result.Latitude, 4);
Assert.Equal(105.8542, result.Longitude, 4);
}
// Integration test với Google Maps API sandbox
[Fact]
public async Task Geocode_IntegrationTest_RealApi()
{
var apiKey = Environment.GetEnvironmentVariable("GOOGLE_MAPS_TEST_API_KEY");
var client = new GoogleGeocodingClient(apiKey);
var result = await client.GeocodeAsync("Hanoi, Vietnam");
Assert.NotNull(result);
Assert.InRange(result.Latitude, 20.5, 21.5);
}
}
Q4: Bạn optimize database queries cho geospatial data như thế nào?
A:
- Spatial indexes: Tạo index trên columns (Latitude, Longitude)
- Bounding box first: Filter nhanh với bounding box trước khi tính distance
- Covering indexes: Include các columns thường select
- Query optimization:
-- Tạo spatial index
CREATE INDEX IX_Locations_Location
ON Locations (Latitude, Longitude)
INCLUDE (LocationName, Category);
-- Query optimized
SELECT TOP 100
LocationName, Latitude, Longitude,
Geography::Point(Latitude, Longitude, 4326).STDistance(
Geography::Point(@userLat, @userLng, 4326)
) AS Distance
FROM Locations
WHERE Latitude BETWEEN @minLat AND @maxLat
AND Longitude BETWEEN @minLng AND @maxLng
ORDER BY Distance;
Q5: Nếu phải redesign lại system, bạn sẽ thay đổi gì?
A:
- Move to microservices: Tách Location Service ra khỏi CMS monolith
- Event sourcing: Track location changes với event stream
- GraphQL API: Cho frontend flexibility hơn REST
- Real-time updates: SignalR cho live location updates
- Better monitoring: Application Insights cho detailed telemetry